mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: rewrite animation system to use bindings and scalar channels
This commit is contained in:
+7
-5
@@ -3,6 +3,7 @@
|
|||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import {
|
import {
|
||||||
buildGraphicParamPath,
|
buildGraphicParamPath,
|
||||||
|
coerceAnimationValueForParam,
|
||||||
getKeyframeAtTime,
|
getKeyframeAtTime,
|
||||||
getParamDefaultInterpolation,
|
getParamDefaultInterpolation,
|
||||||
getParamValueKind,
|
getParamValueKind,
|
||||||
@@ -78,14 +79,15 @@ export function useKeyframedParamProperty({
|
|||||||
propertyPath,
|
propertyPath,
|
||||||
time: localTime,
|
time: localTime,
|
||||||
value,
|
value,
|
||||||
valueKind: getParamValueKind({ param }),
|
kind: getParamValueKind({ param }),
|
||||||
defaultInterpolation: getParamDefaultInterpolation({
|
defaultInterpolation: getParamDefaultInterpolation({
|
||||||
param,
|
param,
|
||||||
}),
|
}),
|
||||||
numericRange:
|
coerceValue: (nextValue) =>
|
||||||
param.type === "number"
|
coerceAnimationValueForParam({
|
||||||
? { min: param.min, max: param.max, step: param.step }
|
param,
|
||||||
: undefined,
|
value: nextValue,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -292,10 +292,7 @@ export function TimelineElement({
|
|||||||
const canToggleCurrentSourceAudio =
|
const canToggleCurrentSourceAudio =
|
||||||
selectedElements.length === 1 &&
|
selectedElements.length === 1 &&
|
||||||
isCurrentElementSelected &&
|
isCurrentElementSelected &&
|
||||||
canToggleSourceAudio({
|
canToggleSourceAudio(element, mediaAsset);
|
||||||
element,
|
|
||||||
mediaAsset,
|
|
||||||
});
|
|
||||||
const sourceAudioLabel =
|
const sourceAudioLabel =
|
||||||
element.type === "video"
|
element.type === "video"
|
||||||
? getSourceAudioActionLabel({ element })
|
? getSourceAudioActionLabel({ element })
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import { OcRippleIcon } from "@/components/icons";
|
import { OcRippleIcon } from "@/components/icons";
|
||||||
|
|
||||||
|
|
||||||
export function TimelineToolbar({
|
export function TimelineToolbar({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
minZoom,
|
minZoom,
|
||||||
@@ -115,10 +116,7 @@ function ToolbarLeftSection() {
|
|||||||
})();
|
})();
|
||||||
const canToggleSelectedSourceAudio =
|
const canToggleSelectedSourceAudio =
|
||||||
!!selectedElement &&
|
!!selectedElement &&
|
||||||
canToggleSourceAudio({
|
canToggleSourceAudio(selectedElement.element, selectedMediaAsset);
|
||||||
element: selectedElement.element,
|
|
||||||
mediaAsset: selectedMediaAsset,
|
|
||||||
});
|
|
||||||
const sourceAudioLabel =
|
const sourceAudioLabel =
|
||||||
selectedElement?.element.type === "video"
|
selectedElement?.element.type === "video"
|
||||||
? getSourceAudioActionLabel({
|
? getSourceAudioActionLabel({
|
||||||
@@ -317,21 +315,21 @@ function ToolbarButton({
|
|||||||
onClick,
|
onClick,
|
||||||
disabled,
|
disabled,
|
||||||
isActive,
|
isActive,
|
||||||
|
buttonWrapper,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
tooltip: string;
|
tooltip: string;
|
||||||
onClick: ({ event }: { event: React.MouseEvent }) => void;
|
onClick?: ({ event }: { event: React.MouseEvent }) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
buttonWrapper?: (button: React.ReactElement) => React.ReactElement;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const button = (
|
||||||
<Tooltip delayDuration={200}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
variant={isActive ? "secondary" : "text"}
|
variant={isActive ? "secondary" : "text"}
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={(event) => onClick({ event })}
|
onClick={onClick ? (event) => onClick({ event }) : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-sm",
|
"rounded-sm",
|
||||||
disabled ? "cursor-not-allowed opacity-50" : "",
|
disabled ? "cursor-not-allowed opacity-50" : "",
|
||||||
@@ -339,6 +337,12 @@ function ToolbarButton({
|
|||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</Button>
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip delayDuration={200}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{buttonWrapper ? buttonWrapper(button) : button}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{tooltip}</TooltipContent>
|
<TooltipContent>{tooltip}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -294,12 +294,7 @@ export function useEditorActions() {
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
if (
|
if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
|
||||||
!canToggleSourceAudio({
|
|
||||||
element: selectedElement.element,
|
|
||||||
mediaAsset,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
|
import { getKeyframeById } from "@/lib/animation";
|
||||||
import { useKeyframeSelection } from "./use-keyframe-selection";
|
import { useKeyframeSelection } from "./use-keyframe-selection";
|
||||||
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
|
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
|
||||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||||
@@ -84,10 +85,11 @@ export function useKeyframeDrag({
|
|||||||
deltaTime: number;
|
deltaTime: number;
|
||||||
}) => {
|
}) => {
|
||||||
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
|
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
|
||||||
const channel = element.animations?.channels[keyframeRef.propertyPath];
|
const keyframe = getKeyframeById({
|
||||||
const keyframe = channel?.keyframes.find(
|
animations: element.animations,
|
||||||
(keyframe) => keyframe.id === keyframeRef.keyframeId,
|
propertyPath: keyframeRef.propertyPath,
|
||||||
);
|
keyframeId: keyframeRef.keyframeId,
|
||||||
|
});
|
||||||
if (!keyframe) return [];
|
if (!keyframe) return [];
|
||||||
const nextTime = Math.max(
|
const nextTime = Math.max(
|
||||||
0,
|
0,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||||
import {
|
import {
|
||||||
getElementLocalTime,
|
getElementLocalTime,
|
||||||
|
hasKeyframesForPath,
|
||||||
resolveTransformAtTime,
|
resolveTransformAtTime,
|
||||||
setChannel,
|
setChannel,
|
||||||
} from "@/lib/animation";
|
} from "@/lib/animation";
|
||||||
@@ -213,8 +214,14 @@ export function useTransformHandles({
|
|||||||
const baseWidth = bounds.width / resolvedTransform.scaleX;
|
const baseWidth = bounds.width / resolvedTransform.scaleX;
|
||||||
const baseHeight = bounds.height / resolvedTransform.scaleY;
|
const baseHeight = bounds.height / resolvedTransform.scaleY;
|
||||||
const shouldClearScaleAnimation =
|
const shouldClearScaleAnimation =
|
||||||
!!element.animations?.channels["transform.scaleX"] ||
|
hasKeyframesForPath({
|
||||||
!!element.animations?.channels["transform.scaleY"];
|
animations: element.animations,
|
||||||
|
propertyPath: "transform.scaleX",
|
||||||
|
}) ||
|
||||||
|
hasKeyframesForPath({
|
||||||
|
animations: element.animations,
|
||||||
|
propertyPath: "transform.scaleY",
|
||||||
|
});
|
||||||
const animationsWithoutScale = shouldClearScaleAnimation
|
const animationsWithoutScale = shouldClearScaleAnimation
|
||||||
? setChannel({
|
? setChannel({
|
||||||
animations: setChannel({
|
animations: setChannel({
|
||||||
@@ -325,7 +332,10 @@ export function useTransformHandles({
|
|||||||
? "transform.scaleX"
|
? "transform.scaleX"
|
||||||
: "transform.scaleY";
|
: "transform.scaleY";
|
||||||
const shouldClearScaleAnimation =
|
const shouldClearScaleAnimation =
|
||||||
!!element.animations?.channels[propertyPath];
|
hasKeyframesForPath({
|
||||||
|
animations: element.animations,
|
||||||
|
propertyPath,
|
||||||
|
});
|
||||||
const animationsWithoutScale = shouldClearScaleAnimation
|
const animationsWithoutScale = shouldClearScaleAnimation
|
||||||
? setChannel({
|
? setChannel({
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -3,15 +3,9 @@ import type { Effect } from "@/lib/effects/types";
|
|||||||
import type {
|
import type {
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
EffectParamPath,
|
EffectParamPath,
|
||||||
NumberAnimationChannel,
|
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import {
|
import { removeElementKeyframe } from "./keyframes";
|
||||||
getChannel,
|
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||||
removeKeyframe,
|
|
||||||
setChannel,
|
|
||||||
upsertKeyframe,
|
|
||||||
} from "./keyframes";
|
|
||||||
import { getChannelValueAtTime } from "./interpolation";
|
|
||||||
|
|
||||||
export const EFFECT_PARAM_PATH_PREFIX = "effects.";
|
export const EFFECT_PARAM_PATH_PREFIX = "effects.";
|
||||||
export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
|
export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
|
||||||
@@ -74,64 +68,19 @@ export function resolveEffectParamsAtTime({
|
|||||||
|
|
||||||
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
|
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
|
||||||
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
|
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
|
||||||
const channel = getChannel({ animations, propertyPath: path });
|
resolved[paramKey] = animations?.bindings[path]
|
||||||
if (channel && channel.keyframes.length > 0) {
|
? resolveAnimationPathValueAtTime({
|
||||||
resolved[paramKey] = getChannelValueAtTime({
|
animations,
|
||||||
channel,
|
propertyPath: path,
|
||||||
time: localTime,
|
localTime,
|
||||||
fallbackValue: staticValue,
|
fallbackValue: staticValue,
|
||||||
}) as number | string | boolean;
|
})
|
||||||
} else {
|
: staticValue;
|
||||||
resolved[paramKey] = staticValue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolved;
|
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({
|
export function removeEffectParamKeyframe({
|
||||||
animations,
|
animations,
|
||||||
effectId,
|
effectId,
|
||||||
@@ -143,12 +92,9 @@ export function removeEffectParamKeyframe({
|
|||||||
paramKey: string;
|
paramKey: string;
|
||||||
keyframeId: string;
|
keyframeId: string;
|
||||||
}): ElementAnimations | undefined {
|
}): ElementAnimations | undefined {
|
||||||
const path = buildEffectParamPath({ effectId, paramKey });
|
return removeElementKeyframe({
|
||||||
const channel = getChannel({ animations, propertyPath: path });
|
|
||||||
const updatedChannel = removeKeyframe({ channel, keyframeId });
|
|
||||||
return setChannel({
|
|
||||||
animations,
|
animations,
|
||||||
propertyPath: path,
|
propertyPath: buildEffectParamPath({ effectId, paramKey }),
|
||||||
channel: updatedChannel,
|
keyframeId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import {
|
|||||||
getGraphicDefinition,
|
getGraphicDefinition,
|
||||||
resolveGraphicParams,
|
resolveGraphicParams,
|
||||||
} from "@/lib/graphics";
|
} from "@/lib/graphics";
|
||||||
import { getChannel } from "./keyframes";
|
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||||
import { getChannelValueAtTime } from "./interpolation";
|
|
||||||
|
|
||||||
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||||
|
|
||||||
@@ -58,19 +57,16 @@ export function resolveGraphicParamsAtTime({
|
|||||||
|
|
||||||
for (const param of definition.params) {
|
for (const param of definition.params) {
|
||||||
const path = buildGraphicParamPath({ paramKey: param.key });
|
const path = buildGraphicParamPath({ paramKey: param.key });
|
||||||
const channel = getChannel({
|
if (!element.animations?.bindings[path]) {
|
||||||
animations: element.animations,
|
|
||||||
propertyPath: path,
|
|
||||||
});
|
|
||||||
if (!channel || channel.keyframes.length === 0) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolved[param.key] = getChannelValueAtTime({
|
resolved[param.key] = resolveAnimationPathValueAtTime({
|
||||||
channel,
|
animations: element.animations,
|
||||||
time: Math.max(0, localTime),
|
propertyPath: path,
|
||||||
|
localTime: Math.max(0, localTime),
|
||||||
fallbackValue: baseParams[param.key] ?? param.default,
|
fallbackValue: baseParams[param.key] ?? param.default,
|
||||||
}) as number | string | boolean;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolved;
|
return resolved;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export {
|
export {
|
||||||
getChannelValueAtTime,
|
getChannelValueAtTime,
|
||||||
getNumberChannelValueAtTime,
|
getDiscreteChannelValueAtTime,
|
||||||
getVectorChannelValueAtTime,
|
getScalarChannelValueAtTime,
|
||||||
|
getScalarSegmentInterpolation,
|
||||||
normalizeChannel,
|
normalizeChannel,
|
||||||
} from "./interpolation";
|
} from "./interpolation";
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
getElementLocalTime,
|
getElementLocalTime,
|
||||||
|
resolveAnimationPathValueAtTime,
|
||||||
resolveColorAtTime,
|
resolveColorAtTime,
|
||||||
resolveNumberAtTime,
|
resolveNumberAtTime,
|
||||||
resolveOpacityAtTime,
|
resolveOpacityAtTime,
|
||||||
@@ -34,12 +36,12 @@ export {
|
|||||||
supportsAnimationProperty,
|
supportsAnimationProperty,
|
||||||
type AnimationPropertyDefinition,
|
type AnimationPropertyDefinition,
|
||||||
type NumericSpec,
|
type NumericSpec,
|
||||||
type NumericRange,
|
|
||||||
withElementBaseValueForProperty,
|
withElementBaseValueForProperty,
|
||||||
} from "./property-registry";
|
} from "./property-registry";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getElementKeyframes,
|
getElementKeyframes,
|
||||||
|
getKeyframeById,
|
||||||
getKeyframeAtTime,
|
getKeyframeAtTime,
|
||||||
hasKeyframesForPath,
|
hasKeyframesForPath,
|
||||||
} from "./keyframe-query";
|
} from "./keyframe-query";
|
||||||
@@ -51,8 +53,17 @@ export {
|
|||||||
resolveGraphicParamsAtTime,
|
resolveGraphicParamsAtTime,
|
||||||
} from "./graphic-param-channel";
|
} from "./graphic-param-channel";
|
||||||
|
|
||||||
|
export {
|
||||||
|
buildEffectParamPath,
|
||||||
|
isEffectParamPath,
|
||||||
|
parseEffectParamPath,
|
||||||
|
removeEffectParamKeyframe,
|
||||||
|
resolveEffectParamsAtTime,
|
||||||
|
} from "./effect-param-channel";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
isAnimationPath,
|
isAnimationPath,
|
||||||
|
coerceAnimationValueForParam,
|
||||||
resolveAnimationTarget,
|
resolveAnimationTarget,
|
||||||
getParamValueKind,
|
getParamValueKind,
|
||||||
getParamDefaultInterpolation,
|
getParamDefaultInterpolation,
|
||||||
@@ -66,6 +77,5 @@ export {
|
|||||||
} from "./property-groups";
|
} from "./property-groups";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getVectorChannelForPath,
|
|
||||||
isVectorValue,
|
isVectorValue,
|
||||||
} from "./vector-channel";
|
} from "./binding-values";
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import type {
|
import type {
|
||||||
AnimationChannel,
|
AnimationChannel,
|
||||||
|
AnimationInterpolation,
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
ColorAnimationChannel,
|
|
||||||
DiscreteValue,
|
|
||||||
DiscreteAnimationChannel,
|
DiscreteAnimationChannel,
|
||||||
NumberAnimationChannel,
|
DiscreteValue,
|
||||||
VectorAnimationChannel,
|
ScalarAnimationChannel,
|
||||||
VectorValue,
|
ScalarAnimationKey,
|
||||||
|
ScalarSegmentType,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import { isVectorValue } from "./vector-channel";
|
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||||
|
import {
|
||||||
|
getBezierPoint,
|
||||||
|
getDefaultLeftHandle,
|
||||||
|
getDefaultRightHandle,
|
||||||
|
solveBezierProgressForTime,
|
||||||
|
} from "./bezier";
|
||||||
|
|
||||||
function byTimeAscending({
|
function byTimeAscending({
|
||||||
leftTime,
|
leftTime,
|
||||||
@@ -40,69 +45,6 @@ function clamp01({ value }: { value: number }): number {
|
|||||||
return Math.max(0, Math.min(1, value));
|
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({
|
function lerpNumber({
|
||||||
leftValue,
|
leftValue,
|
||||||
rightValue,
|
rightValue,
|
||||||
@@ -115,43 +57,99 @@ function lerpNumber({
|
|||||||
return leftValue + (rightValue - leftValue) * progress;
|
return leftValue + (rightValue - leftValue) * progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
function interpolateColor({
|
function normalizeRightHandle({
|
||||||
leftColor,
|
handle,
|
||||||
rightColor,
|
leftKey,
|
||||||
progress,
|
rightKey,
|
||||||
}: {
|
}: {
|
||||||
leftColor: string;
|
handle: ScalarAnimationKey["rightHandle"];
|
||||||
rightColor: string;
|
leftKey: ScalarAnimationKey;
|
||||||
progress: number;
|
rightKey: ScalarAnimationKey;
|
||||||
}): string {
|
}) {
|
||||||
const leftParsed = parseHexColor({ color: leftColor });
|
if (!handle) {
|
||||||
const rightParsed = parseHexColor({ color: rightColor });
|
return undefined;
|
||||||
if (!leftParsed || !rightParsed) {
|
|
||||||
return progress >= 1 ? rightColor : leftColor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatRgbaColor({
|
const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
|
||||||
red: lerpNumber({
|
return {
|
||||||
leftValue: leftParsed.red,
|
dt: Math.min(span, Math.max(0, handle.dt)),
|
||||||
rightValue: rightParsed.red,
|
dv: handle.dv,
|
||||||
progress,
|
};
|
||||||
}),
|
}
|
||||||
green: lerpNumber({
|
|
||||||
leftValue: leftParsed.green,
|
function normalizeLeftHandle({
|
||||||
rightValue: rightParsed.green,
|
handle,
|
||||||
progress,
|
leftKey,
|
||||||
}),
|
rightKey,
|
||||||
blue: lerpNumber({
|
}: {
|
||||||
leftValue: leftParsed.blue,
|
handle: ScalarAnimationKey["leftHandle"];
|
||||||
rightValue: rightParsed.blue,
|
leftKey: ScalarAnimationKey;
|
||||||
progress,
|
rightKey: ScalarAnimationKey;
|
||||||
}),
|
}) {
|
||||||
alpha: lerpNumber({
|
if (!handle) {
|
||||||
leftValue: leftParsed.alpha,
|
return undefined;
|
||||||
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>({
|
export function normalizeChannel<TChannel extends AnimationChannel>({
|
||||||
@@ -159,9 +157,15 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
|
|||||||
}: {
|
}: {
|
||||||
channel: TChannel;
|
channel: TChannel;
|
||||||
}): TChannel {
|
}): TChannel {
|
||||||
|
if (channel.kind === "scalar") {
|
||||||
|
return normalizeScalarChannel({
|
||||||
|
channel,
|
||||||
|
}) as TChannel;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...channel,
|
...channel,
|
||||||
keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) =>
|
keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
|
||||||
byTimeAscending({
|
byTimeAscending({
|
||||||
leftTime: leftKeyframe.time,
|
leftTime: leftKeyframe.time,
|
||||||
rightTime: rightKeyframe.time,
|
rightTime: rightKeyframe.time,
|
||||||
@@ -170,171 +174,152 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
|
|||||||
} as TChannel;
|
} as TChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluateChannelValueAtTime<
|
function extrapolateScalarEdge({
|
||||||
TKeyframe extends { time: number; value: TValue },
|
mode,
|
||||||
TValue,
|
edgeKey,
|
||||||
>({
|
neighborKey,
|
||||||
keyframes,
|
|
||||||
time,
|
time,
|
||||||
fallbackValue,
|
|
||||||
getInterpolatedValue,
|
|
||||||
}: {
|
}: {
|
||||||
keyframes: TKeyframe[] | undefined;
|
mode: "hold" | "linear";
|
||||||
|
edgeKey: ScalarAnimationKey;
|
||||||
|
neighborKey: ScalarAnimationKey | undefined;
|
||||||
time: number;
|
time: number;
|
||||||
fallbackValue: TValue;
|
}) {
|
||||||
getInterpolatedValue: ({
|
if (mode === "hold" || !neighborKey) {
|
||||||
leftKeyframe,
|
return edgeKey.value;
|
||||||
rightKeyframe,
|
|
||||||
progress,
|
|
||||||
}: {
|
|
||||||
leftKeyframe: TKeyframe;
|
|
||||||
rightKeyframe: TKeyframe;
|
|
||||||
progress: number;
|
|
||||||
}) => TValue;
|
|
||||||
}): TValue {
|
|
||||||
if (!keyframes || keyframes.length === 0) {
|
|
||||||
return fallbackValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstKeyframe = keyframes[0];
|
const span = neighborKey.time - edgeKey.time;
|
||||||
const lastKeyframe = keyframes[keyframes.length - 1];
|
|
||||||
if (!firstKeyframe || !lastKeyframe) {
|
|
||||||
return fallbackValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) {
|
|
||||||
return firstKeyframe.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (time >= lastKeyframe.time - TIME_EPSILON_SECONDS) {
|
|
||||||
return lastKeyframe.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (
|
|
||||||
let keyframeIndex = 0;
|
|
||||||
keyframeIndex < keyframes.length - 1;
|
|
||||||
keyframeIndex++
|
|
||||||
) {
|
|
||||||
const leftKeyframe = keyframes[keyframeIndex];
|
|
||||||
const rightKeyframe = keyframes[keyframeIndex + 1];
|
|
||||||
|
|
||||||
if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) {
|
|
||||||
return rightKeyframe.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isBetweenPair = isWithinTimePair({
|
|
||||||
time,
|
|
||||||
leftTime: leftKeyframe.time,
|
|
||||||
rightTime: rightKeyframe.time,
|
|
||||||
});
|
|
||||||
if (!isBetweenPair) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const span = rightKeyframe.time - leftKeyframe.time;
|
|
||||||
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
||||||
return rightKeyframe.value;
|
return edgeKey.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = clamp01({
|
return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value);
|
||||||
value: (time - leftKeyframe.time) / span,
|
|
||||||
});
|
|
||||||
|
|
||||||
return getInterpolatedValue({
|
|
||||||
leftKeyframe,
|
|
||||||
rightKeyframe,
|
|
||||||
progress,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return lastKeyframe.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNumberChannelValueAtTime({
|
export function getScalarSegmentInterpolation({
|
||||||
|
segment,
|
||||||
|
}: {
|
||||||
|
segment: ScalarSegmentType;
|
||||||
|
}): AnimationInterpolation {
|
||||||
|
if (segment === "step") {
|
||||||
|
return "hold";
|
||||||
|
}
|
||||||
|
|
||||||
|
return segment === "bezier" ? "bezier" : "linear";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScalarChannelValueAtTime({
|
||||||
channel,
|
channel,
|
||||||
time,
|
time,
|
||||||
fallbackValue,
|
fallbackValue,
|
||||||
}: {
|
}: {
|
||||||
channel: NumberAnimationChannel | undefined;
|
channel: ScalarAnimationChannel | undefined;
|
||||||
time: number;
|
time: number;
|
||||||
fallbackValue: number;
|
fallbackValue: number;
|
||||||
}): number {
|
}): number {
|
||||||
return evaluateChannelValueAtTime({
|
if (!channel || channel.keys.length === 0) {
|
||||||
keyframes: channel?.keyframes,
|
return fallbackValue;
|
||||||
time,
|
|
||||||
fallbackValue,
|
|
||||||
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
|
|
||||||
if (leftKeyframe.interpolation === "hold") {
|
|
||||||
return leftKeyframe.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedChannel = normalizeChannel({
|
||||||
|
channel,
|
||||||
|
});
|
||||||
|
const firstKey = normalizedChannel.keys[0];
|
||||||
|
const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
|
||||||
|
if (!firstKey || !lastKey) {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time <= firstKey.time + TIME_EPSILON_SECONDS) {
|
||||||
|
if (time < firstKey.time - TIME_EPSILON_SECONDS) {
|
||||||
|
return extrapolateScalarEdge({
|
||||||
|
mode: normalizedChannel.extrapolation?.before ?? "hold",
|
||||||
|
edgeKey: firstKey,
|
||||||
|
neighborKey: normalizedChannel.keys[1],
|
||||||
|
time,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time >= lastKey.time - TIME_EPSILON_SECONDS) {
|
||||||
|
if (time > lastKey.time + TIME_EPSILON_SECONDS) {
|
||||||
|
return extrapolateScalarEdge({
|
||||||
|
mode: normalizedChannel.extrapolation?.after ?? "hold",
|
||||||
|
edgeKey: lastKey,
|
||||||
|
neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2],
|
||||||
|
time,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (
|
||||||
|
let keyIndex = 0;
|
||||||
|
keyIndex < normalizedChannel.keys.length - 1;
|
||||||
|
keyIndex++
|
||||||
|
) {
|
||||||
|
const leftKey = normalizedChannel.keys[keyIndex];
|
||||||
|
const rightKey = normalizedChannel.keys[keyIndex + 1];
|
||||||
|
if (Math.abs(time - rightKey.time) <= TIME_EPSILON_SECONDS) {
|
||||||
|
return rightKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isWithinTimePair({
|
||||||
|
time,
|
||||||
|
leftTime: leftKey.time,
|
||||||
|
rightTime: rightKey.time,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftKey.segmentToNext === "step") {
|
||||||
|
return leftKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = rightKey.time - leftKey.time;
|
||||||
|
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
||||||
|
return rightKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = clamp01({
|
||||||
|
value: (time - leftKey.time) / span,
|
||||||
|
});
|
||||||
|
if (leftKey.segmentToNext === "linear") {
|
||||||
return lerpNumber({
|
return lerpNumber({
|
||||||
leftValue: leftKeyframe.value,
|
leftValue: leftKey.value,
|
||||||
rightValue: rightKeyframe.value,
|
rightValue: rightKey.value,
|
||||||
progress,
|
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({
|
const curveProgress = solveBezierProgressForTime({
|
||||||
leftColor: leftKeyframe.value,
|
|
||||||
rightColor: rightKeyframe.value,
|
|
||||||
progress,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getVectorChannelValueAtTime({
|
|
||||||
channel,
|
|
||||||
time,
|
time,
|
||||||
fallbackValue,
|
leftKey,
|
||||||
}: {
|
rightKey,
|
||||||
channel: VectorAnimationChannel | undefined;
|
});
|
||||||
time: number;
|
const rightHandle =
|
||||||
fallbackValue: VectorValue;
|
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
|
||||||
}): VectorValue {
|
const leftHandle =
|
||||||
return evaluateChannelValueAtTime({
|
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
|
||||||
keyframes: channel?.keyframes,
|
return getBezierPoint({
|
||||||
time,
|
progress: curveProgress,
|
||||||
fallbackValue,
|
p0: leftKey.value,
|
||||||
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
|
p1: leftKey.value + rightHandle.dv,
|
||||||
if (leftKeyframe.interpolation === "hold") {
|
p2: rightKey.value + leftHandle.dv,
|
||||||
return leftKeyframe.value;
|
p3: rightKey.value,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return lastKey.value;
|
||||||
x:
|
|
||||||
leftKeyframe.value.x +
|
|
||||||
(rightKeyframe.value.x - leftKeyframe.value.x) * progress,
|
|
||||||
y:
|
|
||||||
leftKeyframe.value.y +
|
|
||||||
(rightKeyframe.value.y - leftKeyframe.value.y) * progress,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDiscreteValueAtTime({
|
export function getDiscreteChannelValueAtTime({
|
||||||
channel,
|
channel,
|
||||||
time,
|
time,
|
||||||
fallbackValue,
|
fallbackValue,
|
||||||
@@ -343,12 +328,21 @@ function getDiscreteValueAtTime({
|
|||||||
time: number;
|
time: number;
|
||||||
fallbackValue: DiscreteValue;
|
fallbackValue: DiscreteValue;
|
||||||
}): DiscreteValue {
|
}): DiscreteValue {
|
||||||
return evaluateChannelValueAtTime({
|
if (!channel || channel.keys.length === 0) {
|
||||||
keyframes: channel?.keyframes,
|
return fallbackValue;
|
||||||
time,
|
}
|
||||||
fallbackValue,
|
|
||||||
getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value,
|
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({
|
export function getChannelValueAtTime({
|
||||||
@@ -360,50 +354,25 @@ export function getChannelValueAtTime({
|
|||||||
time: number;
|
time: number;
|
||||||
fallbackValue: AnimationValue;
|
fallbackValue: AnimationValue;
|
||||||
}): AnimationValue {
|
}): AnimationValue {
|
||||||
if (!channel || channel.keyframes.length === 0) {
|
if (!channel || channel.keys.length === 0) {
|
||||||
return fallbackValue;
|
return fallbackValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channel.valueKind === "number") {
|
if (channel.kind === "scalar") {
|
||||||
if (typeof fallbackValue !== "number") {
|
return typeof fallbackValue === "number"
|
||||||
return fallbackValue;
|
? getScalarChannelValueAtTime({
|
||||||
}
|
|
||||||
|
|
||||||
return getNumberChannelValueAtTime({
|
|
||||||
channel,
|
channel,
|
||||||
time,
|
time,
|
||||||
fallbackValue,
|
fallbackValue,
|
||||||
});
|
})
|
||||||
}
|
: 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 (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
|
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
|
||||||
return fallbackValue;
|
return fallbackValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getDiscreteValueAtTime({
|
return getDiscreteChannelValueAtTime({
|
||||||
channel,
|
channel,
|
||||||
time,
|
time,
|
||||||
fallbackValue,
|
fallbackValue,
|
||||||
|
|||||||
@@ -1,11 +1,184 @@
|
|||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||||
import type {
|
import type {
|
||||||
|
AnimationBindingInstance,
|
||||||
|
AnimationChannel,
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
ElementKeyframe,
|
ElementKeyframe,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
|
import {
|
||||||
|
type AnimationComponentValue,
|
||||||
|
composeAnimationValue,
|
||||||
|
} from "./binding-values";
|
||||||
|
import {
|
||||||
|
getChannelValueAtTime,
|
||||||
|
getScalarSegmentInterpolation,
|
||||||
|
} from "./interpolation";
|
||||||
import { isAnimationPath } from "./target-resolver";
|
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({
|
export function getElementKeyframes({
|
||||||
animations,
|
animations,
|
||||||
}: {
|
}: {
|
||||||
@@ -15,23 +188,28 @@ export function getElementKeyframes({
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.entries(animations.channels).flatMap(
|
return Object.entries(animations.bindings).flatMap(
|
||||||
([propertyPath, channel]) => {
|
([propertyPath, binding]) => {
|
||||||
if (
|
if (!binding || !isAnimationPath(propertyPath)) {
|
||||||
!channel ||
|
|
||||||
channel.keyframes.length === 0 ||
|
|
||||||
!isAnimationPath(propertyPath)
|
|
||||||
) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return channel.keyframes.map((keyframe) => ({
|
return getUniqueBindingKeyframeMatches({
|
||||||
|
animations,
|
||||||
|
binding,
|
||||||
|
}).flatMap((keyframeMatch) => {
|
||||||
|
const keyframe = toElementKeyframe({
|
||||||
|
animations,
|
||||||
|
binding,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
id: keyframe.id,
|
keyframeMatch,
|
||||||
time: keyframe.time,
|
});
|
||||||
value: keyframe.value,
|
if (!keyframe) {
|
||||||
interpolation: keyframe.interpolation,
|
return [];
|
||||||
}));
|
}
|
||||||
|
|
||||||
|
return [keyframe];
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,8 +221,14 @@ export function hasKeyframesForPath({
|
|||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const channel = animations?.channels[propertyPath];
|
const binding = animations?.bindings[propertyPath];
|
||||||
return Boolean(channel && channel.keyframes.length > 0);
|
if (!binding) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return binding.components.some((component) =>
|
||||||
|
Boolean(animations?.channels[component.channelId]?.keys.length),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getKeyframeAtTime({
|
export function getKeyframeAtTime({
|
||||||
@@ -56,17 +240,59 @@ export function getKeyframeAtTime({
|
|||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
time: number;
|
time: number;
|
||||||
}): ElementKeyframe | null {
|
}): ElementKeyframe | null {
|
||||||
const channel = animations?.channels[propertyPath];
|
const binding = animations?.bindings[propertyPath];
|
||||||
if (!channel || channel.keyframes.length === 0) return null;
|
if (!binding) {
|
||||||
const keyframe = channel.keyframes.find(
|
return null;
|
||||||
(keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
|
}
|
||||||
);
|
|
||||||
if (!keyframe) return null;
|
const keyframeMatch = getPreferredBindingKeyframeMatch({
|
||||||
return {
|
matches: getBindingKeyframeMatches({
|
||||||
|
animations,
|
||||||
|
binding,
|
||||||
|
}).filter(({ keyframe }) =>
|
||||||
|
Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!keyframeMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toElementKeyframe({
|
||||||
|
animations,
|
||||||
|
binding,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
id: keyframe.id,
|
keyframeMatch,
|
||||||
time: keyframe.time,
|
});
|
||||||
value: keyframe.value,
|
}
|
||||||
interpolation: keyframe.interpolation,
|
|
||||||
};
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AnimationBindingKind,
|
||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
AnimationPropertyPath,
|
AnimationPropertyPath,
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
AnimationValueKind,
|
|
||||||
DiscreteValue,
|
|
||||||
VectorValue,
|
VectorValue,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import { isVectorValue } from "./vector-channel";
|
import { isVectorValue, parseColorToLinearRgba } from "./binding-values";
|
||||||
import type { TimelineElement } from "@/lib/timeline";
|
import type { TimelineElement } from "@/lib/timeline";
|
||||||
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
|
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
|
||||||
import {
|
import {
|
||||||
@@ -27,14 +26,13 @@ export interface NumericSpec {
|
|||||||
step?: number;
|
step?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NumericRange = NumericSpec;
|
|
||||||
|
|
||||||
export interface AnimationPropertyDefinition {
|
export interface AnimationPropertyDefinition {
|
||||||
valueKind: AnimationValueKind;
|
kind: AnimationBindingKind;
|
||||||
defaultInterpolation: AnimationInterpolation;
|
defaultInterpolation: AnimationInterpolation;
|
||||||
numericRange?: NumericSpec;
|
numericRanges?: Partial<Record<string, NumericSpec>>;
|
||||||
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
||||||
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
|
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
|
||||||
|
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
|
||||||
setValue: ({
|
setValue: ({
|
||||||
element,
|
element,
|
||||||
value,
|
value,
|
||||||
@@ -44,16 +42,87 @@ export interface AnimationPropertyDefinition {
|
|||||||
}) => TimelineElement;
|
}) => 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<
|
const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
AnimationPropertyPath,
|
AnimationPropertyPath,
|
||||||
AnimationPropertyDefinition
|
AnimationPropertyDefinition
|
||||||
> = {
|
> = {
|
||||||
"transform.position": {
|
"transform.position": {
|
||||||
valueKind: "vector",
|
kind: "vector2",
|
||||||
defaultInterpolation: "linear",
|
defaultInterpolation: "linear",
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
isVisualElement(element) ? element.transform.position : null,
|
isVisualElement(element) ? element.transform.position : null,
|
||||||
|
coerceValue: ({ value }) => (isVectorValue(value) ? value : null),
|
||||||
setValue: ({ element, value }) =>
|
setValue: ({ element, value }) =>
|
||||||
isVisualElement(element)
|
isVisualElement(element)
|
||||||
? {
|
? {
|
||||||
@@ -65,9 +134,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
|||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
},
|
},
|
||||||
"transform.scaleX": {
|
"transform.scaleX": createNumberPropertyDefinition({
|
||||||
valueKind: "number",
|
|
||||||
defaultInterpolation: "linear",
|
|
||||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
@@ -79,10 +146,8 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
|||||||
transform: { ...element.transform, scaleX: value as number },
|
transform: { ...element.transform, scaleX: value as number },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
},
|
}),
|
||||||
"transform.scaleY": {
|
"transform.scaleY": createNumberPropertyDefinition({
|
||||||
valueKind: "number",
|
|
||||||
defaultInterpolation: "linear",
|
|
||||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
@@ -94,10 +159,8 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
|||||||
transform: { ...element.transform, scaleY: value as number },
|
transform: { ...element.transform, scaleY: value as number },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
},
|
}),
|
||||||
"transform.rotate": {
|
"transform.rotate": createNumberPropertyDefinition({
|
||||||
valueKind: "number",
|
|
||||||
defaultInterpolation: "linear",
|
|
||||||
numericRange: { min: -360, max: 360, step: 1 },
|
numericRange: { min: -360, max: 360, step: 1 },
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
@@ -109,10 +172,8 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
|||||||
transform: { ...element.transform, rotate: value as number },
|
transform: { ...element.transform, rotate: value as number },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
},
|
}),
|
||||||
opacity: {
|
opacity: createNumberPropertyDefinition({
|
||||||
valueKind: "number",
|
|
||||||
defaultInterpolation: "linear",
|
|
||||||
numericRange: { min: 0, max: 1, step: 0.01 },
|
numericRange: { min: 0, max: 1, step: 0.01 },
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
@@ -121,10 +182,8 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
|||||||
isVisualElement(element)
|
isVisualElement(element)
|
||||||
? { ...element, opacity: value as number }
|
? { ...element, opacity: value as number }
|
||||||
: element,
|
: element,
|
||||||
},
|
}),
|
||||||
volume: {
|
volume: createNumberPropertyDefinition({
|
||||||
valueKind: "number",
|
|
||||||
defaultInterpolation: "linear",
|
|
||||||
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
|
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
|
||||||
supportsElement: ({ element }) => canElementHaveAudio(element),
|
supportsElement: ({ element }) => canElementHaveAudio(element),
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
@@ -133,23 +192,25 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
|||||||
canElementHaveAudio(element)
|
canElementHaveAudio(element)
|
||||||
? { ...element, volume: value as number }
|
? { ...element, volume: value as number }
|
||||||
: element,
|
: element,
|
||||||
},
|
}),
|
||||||
color: {
|
color: {
|
||||||
valueKind: "color",
|
kind: "color",
|
||||||
defaultInterpolation: "linear",
|
defaultInterpolation: "linear",
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
getValue: ({ element }) => (element.type === "text" ? element.color : null),
|
getValue: ({ element }) => (element.type === "text" ? element.color : null),
|
||||||
|
coerceValue: ({ value }) => coerceColorValue({ value }),
|
||||||
setValue: ({ element, value }) =>
|
setValue: ({ element, value }) =>
|
||||||
element.type === "text"
|
element.type === "text"
|
||||||
? { ...element, color: value as string }
|
? { ...element, color: value as string }
|
||||||
: element,
|
: element,
|
||||||
},
|
},
|
||||||
"background.color": {
|
"background.color": {
|
||||||
valueKind: "color",
|
kind: "color",
|
||||||
defaultInterpolation: "linear",
|
defaultInterpolation: "linear",
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
element.type === "text" ? element.background.color : null,
|
element.type === "text" ? element.background.color : null,
|
||||||
|
coerceValue: ({ value }) => coerceColorValue({ value }),
|
||||||
setValue: ({ element, value }) =>
|
setValue: ({ element, value }) =>
|
||||||
element.type === "text"
|
element.type === "text"
|
||||||
? {
|
? {
|
||||||
@@ -158,9 +219,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
|||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
},
|
},
|
||||||
"background.paddingX": {
|
"background.paddingX": createNumberPropertyDefinition({
|
||||||
valueKind: "number",
|
|
||||||
defaultInterpolation: "linear",
|
|
||||||
numericRange: { min: 0, step: 1 },
|
numericRange: { min: 0, step: 1 },
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
@@ -174,10 +233,8 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
|||||||
background: { ...element.background, paddingX: value as number },
|
background: { ...element.background, paddingX: value as number },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
},
|
}),
|
||||||
"background.paddingY": {
|
"background.paddingY": createNumberPropertyDefinition({
|
||||||
valueKind: "number",
|
|
||||||
defaultInterpolation: "linear",
|
|
||||||
numericRange: { min: 0, step: 1 },
|
numericRange: { min: 0, step: 1 },
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
@@ -191,10 +248,8 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
|||||||
background: { ...element.background, paddingY: value as number },
|
background: { ...element.background, paddingY: value as number },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
},
|
}),
|
||||||
"background.offsetX": {
|
"background.offsetX": createNumberPropertyDefinition({
|
||||||
valueKind: "number",
|
|
||||||
defaultInterpolation: "linear",
|
|
||||||
numericRange: { step: 1 },
|
numericRange: { step: 1 },
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
@@ -208,10 +263,8 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
|||||||
background: { ...element.background, offsetX: value as number },
|
background: { ...element.background, offsetX: value as number },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
},
|
}),
|
||||||
"background.offsetY": {
|
"background.offsetY": createNumberPropertyDefinition({
|
||||||
valueKind: "number",
|
|
||||||
defaultInterpolation: "linear",
|
|
||||||
numericRange: { step: 1 },
|
numericRange: { step: 1 },
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
@@ -225,11 +278,13 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
|||||||
background: { ...element.background, offsetY: value as number },
|
background: { ...element.background, offsetY: value as number },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
|
}),
|
||||||
|
"background.cornerRadius": createNumberPropertyDefinition({
|
||||||
|
numericRange: {
|
||||||
|
min: CORNER_RADIUS_MIN,
|
||||||
|
max: CORNER_RADIUS_MAX,
|
||||||
|
step: 1,
|
||||||
},
|
},
|
||||||
"background.cornerRadius": {
|
|
||||||
valueKind: "number",
|
|
||||||
defaultInterpolation: "linear",
|
|
||||||
numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 },
|
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
element.type === "text"
|
element.type === "text"
|
||||||
@@ -242,7 +297,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
|
|||||||
background: { ...element.background, cornerRadius: value as number },
|
background: { ...element.background, cornerRadius: value as number },
|
||||||
}
|
}
|
||||||
: element,
|
: element,
|
||||||
},
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isAnimationPropertyPath(
|
export function isAnimationPropertyPath(
|
||||||
@@ -293,12 +348,9 @@ export function withElementBaseValueForProperty({
|
|||||||
propertyPath: AnimationPropertyPath;
|
propertyPath: AnimationPropertyPath;
|
||||||
value: AnimationValue;
|
value: AnimationValue;
|
||||||
}): TimelineElement {
|
}): TimelineElement {
|
||||||
const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
|
|
||||||
if (coercedValue === null) {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||||
if (!definition.supportsElement({ element })) {
|
const coercedValue = definition.coerceValue({ value });
|
||||||
|
if (coercedValue === null || !definition.supportsElement({ element })) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
return definition.setValue({ element, value: coercedValue });
|
return definition.setValue({ element, value: coercedValue });
|
||||||
@@ -313,26 +365,6 @@ export function getDefaultInterpolationForProperty({
|
|||||||
return propertyDefinition.defaultInterpolation;
|
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({
|
export function coerceAnimationValueForProperty({
|
||||||
propertyPath,
|
propertyPath,
|
||||||
value,
|
value,
|
||||||
@@ -341,29 +373,5 @@ export function coerceAnimationValueForProperty({
|
|||||||
value: AnimationValue;
|
value: AnimationValue;
|
||||||
}): AnimationValue | null {
|
}): AnimationValue | null {
|
||||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||||
|
return propertyDefinition.coerceValue({ value });
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import type {
|
|||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import type { Transform } from "@/lib/rendering";
|
import type { Transform } from "@/lib/rendering";
|
||||||
import {
|
import {
|
||||||
getColorValueAtTime,
|
type AnimationComponentValue,
|
||||||
getNumberChannelValueAtTime,
|
composeAnimationValue,
|
||||||
getVectorChannelValueAtTime,
|
decomposeAnimationValue,
|
||||||
|
} from "./binding-values";
|
||||||
|
import {
|
||||||
|
getChannelValueAtTime,
|
||||||
} from "./interpolation";
|
} from "./interpolation";
|
||||||
import { getColorChannelForPath } from "./color-channel";
|
|
||||||
import { getNumberChannelForPath } from "./number-channel";
|
|
||||||
import { getVectorChannelForPath } from "./vector-channel";
|
|
||||||
|
|
||||||
export function getElementLocalTime({
|
export function getElementLocalTime({
|
||||||
timelineTime,
|
timelineTime,
|
||||||
@@ -44,36 +44,28 @@ export function resolveTransformAtTime({
|
|||||||
}): Transform {
|
}): Transform {
|
||||||
const safeLocalTime = Math.max(0, localTime);
|
const safeLocalTime = Math.max(0, localTime);
|
||||||
return {
|
return {
|
||||||
position: getVectorChannelValueAtTime({
|
position: resolveAnimationPathValueAtTime({
|
||||||
channel: getVectorChannelForPath({
|
|
||||||
animations,
|
animations,
|
||||||
propertyPath: "transform.position",
|
propertyPath: "transform.position",
|
||||||
}),
|
localTime: safeLocalTime,
|
||||||
time: safeLocalTime,
|
|
||||||
fallbackValue: baseTransform.position,
|
fallbackValue: baseTransform.position,
|
||||||
}),
|
}),
|
||||||
scaleX: getNumberChannelValueAtTime({
|
scaleX: resolveAnimationPathValueAtTime({
|
||||||
channel: getNumberChannelForPath({
|
|
||||||
animations,
|
animations,
|
||||||
propertyPath: "transform.scaleX",
|
propertyPath: "transform.scaleX",
|
||||||
}),
|
localTime: safeLocalTime,
|
||||||
time: safeLocalTime,
|
|
||||||
fallbackValue: baseTransform.scaleX,
|
fallbackValue: baseTransform.scaleX,
|
||||||
}),
|
}),
|
||||||
scaleY: getNumberChannelValueAtTime({
|
scaleY: resolveAnimationPathValueAtTime({
|
||||||
channel: getNumberChannelForPath({
|
|
||||||
animations,
|
animations,
|
||||||
propertyPath: "transform.scaleY",
|
propertyPath: "transform.scaleY",
|
||||||
}),
|
localTime: safeLocalTime,
|
||||||
time: safeLocalTime,
|
|
||||||
fallbackValue: baseTransform.scaleY,
|
fallbackValue: baseTransform.scaleY,
|
||||||
}),
|
}),
|
||||||
rotate: getNumberChannelValueAtTime({
|
rotate: resolveAnimationPathValueAtTime({
|
||||||
channel: getNumberChannelForPath({
|
|
||||||
animations,
|
animations,
|
||||||
propertyPath: "transform.rotate",
|
propertyPath: "transform.rotate",
|
||||||
}),
|
localTime: safeLocalTime,
|
||||||
time: safeLocalTime,
|
|
||||||
fallbackValue: baseTransform.rotate,
|
fallbackValue: baseTransform.rotate,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -88,12 +80,10 @@ export function resolveOpacityAtTime({
|
|||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
}): number {
|
}): number {
|
||||||
return getNumberChannelValueAtTime({
|
return resolveAnimationPathValueAtTime({
|
||||||
channel: getNumberChannelForPath({
|
|
||||||
animations,
|
animations,
|
||||||
propertyPath: "opacity",
|
propertyPath: "opacity",
|
||||||
}),
|
localTime: Math.max(0, localTime),
|
||||||
time: Math.max(0, localTime),
|
|
||||||
fallbackValue: baseOpacity,
|
fallbackValue: baseOpacity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -109,9 +99,10 @@ export function resolveNumberAtTime({
|
|||||||
propertyPath: AnimationPropertyPath;
|
propertyPath: AnimationPropertyPath;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
}): number {
|
}): number {
|
||||||
return getNumberChannelValueAtTime({
|
return resolveAnimationPathValueAtTime({
|
||||||
channel: getNumberChannelForPath({ animations, propertyPath }),
|
animations,
|
||||||
time: Math.max(0, localTime),
|
propertyPath,
|
||||||
|
localTime: Math.max(0, localTime),
|
||||||
fallbackValue: baseValue,
|
fallbackValue: baseValue,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -127,9 +118,57 @@ export function resolveColorAtTime({
|
|||||||
propertyPath: AnimationPropertyPath;
|
propertyPath: AnimationPropertyPath;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
}): string {
|
}): string {
|
||||||
return getColorValueAtTime({
|
return resolveAnimationPathValueAtTime({
|
||||||
channel: getColorChannelForPath({ animations, propertyPath }),
|
animations,
|
||||||
time: Math.max(0, localTime),
|
propertyPath,
|
||||||
|
localTime: Math.max(0, localTime),
|
||||||
fallbackValue: baseColor,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AnimationBindingKind,
|
||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
AnimationPath,
|
AnimationPath,
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
AnimationValueKind,
|
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import {
|
import {
|
||||||
parseEffectParamPath,
|
parseEffectParamPath,
|
||||||
@@ -26,11 +26,13 @@ import {
|
|||||||
type NumericSpec,
|
type NumericSpec,
|
||||||
withElementBaseValueForProperty,
|
withElementBaseValueForProperty,
|
||||||
} from "./property-registry";
|
} from "./property-registry";
|
||||||
|
import { parseColorToLinearRgba } from "./binding-values";
|
||||||
|
|
||||||
export interface AnimationPathDescriptor {
|
export interface AnimationPathDescriptor {
|
||||||
valueKind: AnimationValueKind;
|
kind: AnimationBindingKind;
|
||||||
defaultInterpolation: AnimationInterpolation;
|
defaultInterpolation: AnimationInterpolation;
|
||||||
numericRange?: NumericSpec;
|
numericRanges?: Partial<Record<string, NumericSpec>>;
|
||||||
|
coerceValue(value: AnimationValue): AnimationValue | null;
|
||||||
getBaseValue(): AnimationValue | null;
|
getBaseValue(): AnimationValue | null;
|
||||||
setBaseValue(value: AnimationValue): TimelineElement;
|
setBaseValue(value: AnimationValue): TimelineElement;
|
||||||
}
|
}
|
||||||
@@ -39,7 +41,7 @@ export function getParamValueKind({
|
|||||||
param,
|
param,
|
||||||
}: {
|
}: {
|
||||||
param: ParamDefinition;
|
param: ParamDefinition;
|
||||||
}): AnimationValueKind {
|
}): AnimationBindingKind {
|
||||||
if (param.type === "number") {
|
if (param.type === "number") {
|
||||||
return "number";
|
return "number";
|
||||||
}
|
}
|
||||||
@@ -63,19 +65,21 @@ function getParamNumericRange({
|
|||||||
param,
|
param,
|
||||||
}: {
|
}: {
|
||||||
param: ParamDefinition;
|
param: ParamDefinition;
|
||||||
}): NumericSpec | undefined {
|
}): Partial<Record<string, NumericSpec>> | undefined {
|
||||||
if (param.type !== "number") {
|
if (param.type !== "number") {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
value: {
|
||||||
min: param.min,
|
min: param.min,
|
||||||
max: param.max,
|
max: param.max,
|
||||||
step: param.step,
|
step: param.step,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function coerceParamValue({
|
export function coerceAnimationValueForParam({
|
||||||
param,
|
param,
|
||||||
value,
|
value,
|
||||||
}: {
|
}: {
|
||||||
@@ -128,12 +132,13 @@ function buildGraphicParamDescriptor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valueKind: getParamValueKind({ param }),
|
kind: getParamValueKind({ param }),
|
||||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||||
numericRange: getParamNumericRange({ param }),
|
numericRanges: getParamNumericRange({ param }),
|
||||||
|
coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
|
||||||
getBaseValue: () => element.params[param.key] ?? param.default,
|
getBaseValue: () => element.params[param.key] ?? param.default,
|
||||||
setBaseValue: (value) => {
|
setBaseValue: (value) => {
|
||||||
const coercedValue = coerceParamValue({ param, value });
|
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||||
if (coercedValue === null) {
|
if (coercedValue === null) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
@@ -175,12 +180,13 @@ function buildEffectParamDescriptor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valueKind: getParamValueKind({ param }),
|
kind: getParamValueKind({ param }),
|
||||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||||
numericRange: getParamNumericRange({ param }),
|
numericRanges: getParamNumericRange({ param }),
|
||||||
|
coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
|
||||||
getBaseValue: () => effect.params[param.key] ?? param.default,
|
getBaseValue: () => effect.params[param.key] ?? param.default,
|
||||||
setBaseValue: (value) => {
|
setBaseValue: (value) => {
|
||||||
const coercedValue = coerceParamValue({ param, value });
|
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||||
if (coercedValue === null) {
|
if (coercedValue === null) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
@@ -230,19 +236,21 @@ export function resolveAnimationTarget({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valueKind: propertyDefinition.valueKind,
|
kind: propertyDefinition.kind,
|
||||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||||
numericRange: propertyDefinition.numericRange,
|
numericRanges: propertyDefinition.numericRanges,
|
||||||
|
coerceValue: (value) =>
|
||||||
|
coerceAnimationValueForProperty({
|
||||||
|
propertyPath: path,
|
||||||
|
value,
|
||||||
|
}),
|
||||||
getBaseValue: () =>
|
getBaseValue: () =>
|
||||||
getElementBaseValueForProperty({
|
getElementBaseValueForProperty({
|
||||||
element,
|
element,
|
||||||
propertyPath: path,
|
propertyPath: path,
|
||||||
}),
|
}),
|
||||||
setBaseValue: (value) => {
|
setBaseValue: (value) => {
|
||||||
const coercedValue = coerceAnimationValueForProperty({
|
const coercedValue = propertyDefinition.coerceValue({ value });
|
||||||
propertyPath: path,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
if (coercedValue === null) {
|
if (coercedValue === null) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,77 +29,118 @@ export const ANIMATION_PROPERTY_GROUPS = {
|
|||||||
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
|
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
|
||||||
|
|
||||||
export type VectorValue = { x: number; y: number };
|
export type VectorValue = { x: number; y: number };
|
||||||
|
|
||||||
export type AnimationValueKind = "number" | "color" | "discrete" | "vector";
|
|
||||||
export type DiscreteValue = boolean | string;
|
export type DiscreteValue = boolean | string;
|
||||||
export type AnimationValue = number | string | boolean | VectorValue;
|
export type AnimationValue = number | string | boolean | VectorValue;
|
||||||
|
|
||||||
export type ContinuousKeyframeInterpolation = "linear" | "hold";
|
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
|
||||||
export type DiscreteKeyframeInterpolation = "hold";
|
export type DiscreteKeyframeInterpolation = "hold";
|
||||||
export type AnimationInterpolation =
|
export type AnimationInterpolation =
|
||||||
| ContinuousKeyframeInterpolation
|
| ContinuousKeyframeInterpolation
|
||||||
| DiscreteKeyframeInterpolation;
|
| DiscreteKeyframeInterpolation;
|
||||||
|
|
||||||
interface BaseAnimationKeyframe<
|
export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
|
||||||
TValue extends AnimationValue,
|
export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
|
||||||
TInterpolation extends AnimationInterpolation,
|
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;
|
id: string;
|
||||||
time: number; // relative to element start time
|
time: number; // relative to element start time
|
||||||
value: TValue;
|
value: TValue;
|
||||||
interpolation: TInterpolation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NumberKeyframe
|
export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
|
||||||
extends BaseAnimationKeyframe<number, ContinuousKeyframeInterpolation> {}
|
leftHandle?: CurveHandle;
|
||||||
|
rightHandle?: CurveHandle;
|
||||||
export interface ColorKeyframe
|
segmentToNext: ScalarSegmentType;
|
||||||
extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {}
|
tangentMode: TangentMode;
|
||||||
|
|
||||||
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
|
export interface DiscreteAnimationKey
|
||||||
extends BaseAnimationChannel<"number", NumberKeyframe> {}
|
extends BaseAnimationKeyframe<DiscreteValue> {}
|
||||||
|
|
||||||
export interface ColorAnimationChannel
|
export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
|
||||||
extends BaseAnimationChannel<"color", ColorKeyframe> {}
|
|
||||||
|
|
||||||
export interface DiscreteAnimationChannel
|
export interface ScalarAnimationChannel {
|
||||||
extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {}
|
kind: "scalar";
|
||||||
|
keys: ScalarAnimationKey[];
|
||||||
|
extrapolation?: {
|
||||||
|
before: ChannelExtrapolationMode;
|
||||||
|
after: ChannelExtrapolationMode;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface VectorAnimationChannel
|
export interface DiscreteAnimationChannel {
|
||||||
extends BaseAnimationChannel<"vector", VectorKeyframe> {}
|
kind: "discrete";
|
||||||
|
keys: DiscreteAnimationKey[];
|
||||||
|
}
|
||||||
|
|
||||||
export type AnimationChannel =
|
export type AnimationChannel =
|
||||||
| NumberAnimationChannel
|
| ScalarAnimationChannel
|
||||||
| ColorAnimationChannel
|
| DiscreteAnimationChannel;
|
||||||
| DiscreteAnimationChannel
|
|
||||||
| VectorAnimationChannel;
|
|
||||||
|
|
||||||
export type ElementAnimationChannelMap = Record<
|
export type ElementAnimationChannelMap = Record<
|
||||||
string,
|
string,
|
||||||
AnimationChannel | undefined
|
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 {
|
export interface ElementAnimations {
|
||||||
|
bindings: ElementAnimationBindingMap;
|
||||||
channels: ElementAnimationChannelMap;
|
channels: ElementAnimationChannelMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import {
|
import {
|
||||||
getChannel,
|
hasKeyframesForPath,
|
||||||
getChannelValueAtTime,
|
getKeyframeById,
|
||||||
removeElementKeyframe,
|
removeElementKeyframe,
|
||||||
|
resolveAnimationPathValueAtTime,
|
||||||
resolveAnimationTarget,
|
resolveAnimationTarget,
|
||||||
} from "@/lib/animation";
|
} from "@/lib/animation";
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||||
@@ -19,17 +20,6 @@ function sampleValueBeforeRemoval({
|
|||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
keyframeId: string;
|
keyframeId: string;
|
||||||
}): AnimationValue | null {
|
}): 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 });
|
const target = resolveAnimationTarget({ element, path: propertyPath });
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return null;
|
return null;
|
||||||
@@ -39,9 +29,19 @@ function sampleValueBeforeRemoval({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getChannelValueAtTime({
|
const keyframe = getKeyframeById({
|
||||||
channel,
|
animations: element.animations,
|
||||||
time: keyframe.time,
|
propertyPath,
|
||||||
|
keyframeId,
|
||||||
|
});
|
||||||
|
if (!keyframe) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveAnimationPathValueAtTime({
|
||||||
|
animations: element.animations,
|
||||||
|
propertyPath,
|
||||||
|
localTime: keyframe.time,
|
||||||
fallbackValue: baseValue,
|
fallbackValue: baseValue,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -72,8 +72,10 @@ function removeKeyframeAndPersist({
|
|||||||
keyframeId,
|
keyframeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isChannelNowEmpty =
|
const isChannelNowEmpty = !hasKeyframesForPath({
|
||||||
getChannel({ animations: nextAnimations, propertyPath }) === undefined;
|
animations: nextAnimations,
|
||||||
|
propertyPath,
|
||||||
|
});
|
||||||
const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
|
const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
|
||||||
|
|
||||||
const baseElement = shouldPersistToBase
|
const baseElement = shouldPersistToBase
|
||||||
|
|||||||
+26
-7
@@ -1,8 +1,13 @@
|
|||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
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 { updateElementInTracks } from "@/lib/timeline";
|
||||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||||
|
import type { AnimationInterpolation } from "@/lib/animation/types";
|
||||||
import type { TimelineTrack } from "@/lib/timeline";
|
import type { TimelineTrack } from "@/lib/timeline";
|
||||||
|
|
||||||
export class UpsertEffectParamKeyframeCommand extends Command {
|
export class UpsertEffectParamKeyframeCommand extends Command {
|
||||||
@@ -12,8 +17,8 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
|||||||
private readonly effectId: string;
|
private readonly effectId: string;
|
||||||
private readonly paramKey: string;
|
private readonly paramKey: string;
|
||||||
private readonly time: number;
|
private readonly time: number;
|
||||||
private readonly value: number;
|
private readonly value: number | string | boolean;
|
||||||
private readonly interpolation: "linear" | "hold" | undefined;
|
private readonly interpolation: AnimationInterpolation | undefined;
|
||||||
private readonly keyframeId: string | undefined;
|
private readonly keyframeId: string | undefined;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
@@ -31,8 +36,8 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
|||||||
effectId: string;
|
effectId: string;
|
||||||
paramKey: string;
|
paramKey: string;
|
||||||
time: number;
|
time: number;
|
||||||
value: number;
|
value: number | string | boolean;
|
||||||
interpolation?: "linear" | "hold";
|
interpolation?: AnimationInterpolation;
|
||||||
keyframeId?: string;
|
keyframeId?: string;
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
@@ -57,14 +62,28 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
|||||||
elementPredicate: isVisualElement,
|
elementPredicate: isVisualElement,
|
||||||
update: (element) => {
|
update: (element) => {
|
||||||
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
||||||
const animations = upsertEffectParamKeyframe({
|
const propertyPath = buildEffectParamPath({
|
||||||
animations: element.animations,
|
|
||||||
effectId: this.effectId,
|
effectId: this.effectId,
|
||||||
paramKey: this.paramKey,
|
paramKey: this.paramKey,
|
||||||
|
});
|
||||||
|
const target = resolveAnimationTarget({
|
||||||
|
element,
|
||||||
|
path: propertyPath,
|
||||||
|
});
|
||||||
|
if (!target) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
const animations = upsertPathKeyframe({
|
||||||
|
animations: element.animations,
|
||||||
|
propertyPath,
|
||||||
time: boundedTime,
|
time: boundedTime,
|
||||||
value: this.value,
|
value: this.value,
|
||||||
interpolation: this.interpolation,
|
interpolation: this.interpolation,
|
||||||
keyframeId: this.keyframeId,
|
keyframeId: this.keyframeId,
|
||||||
|
kind: target.kind,
|
||||||
|
defaultInterpolation: target.defaultInterpolation,
|
||||||
|
coerceValue: target.coerceValue,
|
||||||
});
|
});
|
||||||
return { ...element, animations };
|
return { ...element, animations };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -73,9 +73,9 @@ export class UpsertKeyframeCommand extends Command {
|
|||||||
value: this.value,
|
value: this.value,
|
||||||
interpolation: this.interpolation,
|
interpolation: this.interpolation,
|
||||||
keyframeId: this.keyframeId,
|
keyframeId: this.keyframeId,
|
||||||
valueKind: target.valueKind,
|
kind: target.kind,
|
||||||
defaultInterpolation: target.defaultInterpolation,
|
defaultInterpolation: target.defaultInterpolation,
|
||||||
numericRange: target.numericRange,
|
coerceValue: target.coerceValue,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export class ToggleSourceAudioSeparationCommand extends Command {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canRecoverSourceAudio({ element: sourceElement })) {
|
if (canRecoverSourceAudio(sourceElement)) {
|
||||||
editor.timeline.updateTracks(
|
editor.timeline.updateTracks(
|
||||||
updateSourceAudioEnabled({
|
updateSourceAudioEnabled({
|
||||||
tracks: this.savedState,
|
tracks: this.savedState,
|
||||||
@@ -63,7 +63,7 @@ export class ToggleSourceAudioSeparationCommand extends Command {
|
|||||||
.media
|
.media
|
||||||
.getAssets()
|
.getAssets()
|
||||||
.find((asset) => asset.id === sourceElement.mediaId);
|
.find((asset) => asset.id === sourceElement.mediaId);
|
||||||
if (!canExtractSourceAudio({ element: sourceElement, mediaAsset })) {
|
if (!canExtractSourceAudio(sourceElement, mediaAsset)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (sourceElement.duration <= 0) {
|
if (sourceElement.duration <= 0) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function buildSplitParams(
|
|||||||
inverted: false,
|
inverted: false,
|
||||||
strokeColor: "#ffffff",
|
strokeColor: "#ffffff",
|
||||||
strokeWidth: 0,
|
strokeWidth: 0,
|
||||||
|
strokeAlign: "center",
|
||||||
centerX: 0,
|
centerX: 0,
|
||||||
centerY: 0,
|
centerY: 0,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
@@ -46,6 +47,7 @@ function buildRectangleParams(
|
|||||||
inverted: false,
|
inverted: false,
|
||||||
strokeColor: "#ffffff",
|
strokeColor: "#ffffff",
|
||||||
strokeWidth: 0,
|
strokeWidth: 0,
|
||||||
|
strokeAlign: "center",
|
||||||
centerX: 0,
|
centerX: 0,
|
||||||
centerY: 0,
|
centerY: 0,
|
||||||
width: 0.4,
|
width: 0.4,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
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 type { AudioElement, VideoElement } from "@/lib/timeline";
|
||||||
import {
|
import {
|
||||||
buildSeparatedAudioElement,
|
buildSeparatedAudioElement,
|
||||||
@@ -25,32 +27,7 @@ describe("audio separation", () => {
|
|||||||
volume: -6,
|
volume: -6,
|
||||||
muted: true,
|
muted: true,
|
||||||
retime: { rate: 1.25, maintainPitch: true },
|
retime: { rate: 1.25, maintainPitch: true },
|
||||||
animations: {
|
animations: buildAnimations(),
|
||||||
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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const separatedAudioElement = buildSeparatedAudioElement({
|
const separatedAudioElement = buildSeparatedAudioElement({
|
||||||
@@ -71,21 +48,22 @@ describe("audio separation", () => {
|
|||||||
muted: true,
|
muted: true,
|
||||||
retime: { rate: 1.25, maintainPitch: true },
|
retime: { rate: 1.25, maintainPitch: true },
|
||||||
});
|
});
|
||||||
expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([
|
expect(Object.keys(separatedAudioElement.animations?.bindings ?? {})).toEqual([
|
||||||
"volume",
|
"volume",
|
||||||
]);
|
]);
|
||||||
|
expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([
|
||||||
|
"volume:value",
|
||||||
|
]);
|
||||||
expect(
|
expect(
|
||||||
separatedAudioElement.animations?.channels.volume?.keyframes[0]?.id,
|
separatedAudioElement.animations?.channels["volume:value"]?.keys[0]?.id,
|
||||||
).not.toBe("volume-keyframe");
|
).not.toBe("volume-keyframe");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("skips source audio collection when the source clip is separated", () => {
|
test("skips source audio collection when the source clip is separated", () => {
|
||||||
const mediaAsset = {
|
const mediaAsset: MediaAsset = {
|
||||||
id: "media-1",
|
id: "media-1",
|
||||||
type: "video",
|
type: "video",
|
||||||
name: "Clip",
|
name: "Clip",
|
||||||
size: 1,
|
|
||||||
lastModified: 1,
|
|
||||||
file: new File(["video"], "clip.mp4", { type: "video/mp4" }),
|
file: new File(["video"], "clip.mp4", { type: "video/mp4" }),
|
||||||
url: "blob:clip",
|
url: "blob:clip",
|
||||||
hasAudio: true,
|
hasAudio: true,
|
||||||
@@ -145,3 +123,23 @@ function buildVideoElement(
|
|||||||
...overrides,
|
...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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cloneAnimations, getChannel } from "@/lib/animation";
|
import { cloneAnimations } from "@/lib/animation";
|
||||||
import type { ElementAnimations } from "@/lib/animation/types";
|
import type { ElementAnimations } from "@/lib/animation/types";
|
||||||
import type { MediaAsset } from "@/lib/media/types";
|
import type { MediaAsset } from "@/lib/media/types";
|
||||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||||
@@ -25,13 +25,10 @@ export function isSourceAudioSeparated({
|
|||||||
return !isSourceAudioEnabled({ element });
|
return !isSourceAudioEnabled({ element });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canExtractSourceAudio({
|
export function canExtractSourceAudio(
|
||||||
element,
|
element: TimelineElement,
|
||||||
mediaAsset,
|
mediaAsset: MediaAsset | null | undefined,
|
||||||
}: {
|
): element is VideoElement {
|
||||||
element: TimelineElement;
|
|
||||||
mediaAsset: MediaAsset | null | undefined;
|
|
||||||
}): element is VideoElement {
|
|
||||||
return (
|
return (
|
||||||
element.type === "video" &&
|
element.type === "video" &&
|
||||||
isSourceAudioEnabled({ element }) &&
|
isSourceAudioEnabled({ element }) &&
|
||||||
@@ -40,25 +37,17 @@ export function canExtractSourceAudio({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canRecoverSourceAudio({
|
export function canRecoverSourceAudio(
|
||||||
element,
|
element: TimelineElement,
|
||||||
}: {
|
): element is VideoElement {
|
||||||
element: TimelineElement;
|
|
||||||
}): element is VideoElement {
|
|
||||||
return element.type === "video" && isSourceAudioSeparated({ element });
|
return element.type === "video" && isSourceAudioSeparated({ element });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canToggleSourceAudio({
|
export function canToggleSourceAudio(
|
||||||
element,
|
element: TimelineElement,
|
||||||
mediaAsset,
|
mediaAsset: MediaAsset | null | undefined,
|
||||||
}: {
|
): element is VideoElement {
|
||||||
element: TimelineElement;
|
return canRecoverSourceAudio(element) || canExtractSourceAudio(element, mediaAsset);
|
||||||
mediaAsset: MediaAsset | null | undefined;
|
|
||||||
}): element is VideoElement {
|
|
||||||
return (
|
|
||||||
canRecoverSourceAudio({ element }) ||
|
|
||||||
canExtractSourceAudio({ element, mediaAsset })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function doesElementHaveEnabledAudio({
|
export function doesElementHaveEnabledAudio({
|
||||||
@@ -117,16 +106,27 @@ function cloneVolumeAnimations({
|
|||||||
}: {
|
}: {
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
}): ElementAnimations | undefined {
|
}): ElementAnimations | undefined {
|
||||||
const volumeChannel = getChannel({ animations, propertyPath: "volume" });
|
const volumeBinding = animations?.bindings.volume;
|
||||||
if (!volumeChannel) {
|
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 undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cloneAnimations({
|
return cloneAnimations({
|
||||||
animations: {
|
animations: {
|
||||||
channels: {
|
bindings: {
|
||||||
volume: volumeChannel,
|
volume: volumeBinding,
|
||||||
},
|
},
|
||||||
|
channels: subsetChannels,
|
||||||
},
|
},
|
||||||
shouldRegenerateKeyframeIds: true,
|
shouldRegenerateKeyframeIds: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,13 +29,15 @@ describe("V5 to V6 Migration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("skips projects that are already v6", () => {
|
test("skips projects that are already v6", () => {
|
||||||
|
const firstScene = (v5Project as { scenes: Array<Record<string, unknown>> })
|
||||||
|
.scenes[0];
|
||||||
const result = transformProjectV5ToV6({
|
const result = transformProjectV5ToV6({
|
||||||
project: {
|
project: {
|
||||||
...v5Project,
|
...v5Project,
|
||||||
version: 6,
|
version: 6,
|
||||||
scenes: [
|
scenes: [
|
||||||
{
|
{
|
||||||
...(v5Project as { scenes: unknown[] }).scenes[0],
|
...firstScene,
|
||||||
bookmarks: [{ time: 2 }, { time: 5 }],
|
bookmarks: [{ time: 2 }, { time: 5 }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ import { V17toV18Migration } from "./v17-to-v18";
|
|||||||
import { V18toV19Migration } from "./v18-to-v19";
|
import { V18toV19Migration } from "./v18-to-v19";
|
||||||
import { V19toV20Migration } from "./v19-to-v20";
|
import { V19toV20Migration } from "./v19-to-v20";
|
||||||
import { V20toV21Migration } from "./v20-to-v21";
|
import { V20toV21Migration } from "./v20-to-v21";
|
||||||
|
import { V21toV22Migration } from "./v21-to-v22";
|
||||||
export { runStorageMigrations } from "./runner";
|
export { runStorageMigrations } from "./runner";
|
||||||
export type { MigrationProgress } from "./runner";
|
export type { MigrationProgress } from "./runner";
|
||||||
|
|
||||||
export const CURRENT_PROJECT_VERSION = 21;
|
export const CURRENT_PROJECT_VERSION = 22;
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
new V0toV1Migration(),
|
new V0toV1Migration(),
|
||||||
@@ -47,4 +48,5 @@ export const migrations = [
|
|||||||
new V18toV19Migration(),
|
new V18toV19Migration(),
|
||||||
new V19toV20Migration(),
|
new V19toV20Migration(),
|
||||||
new V20toV21Migration(),
|
new V20toV21Migration(),
|
||||||
|
new V21toV22Migration(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user