refactor: rewrite animation system to use bindings and scalar channels
This commit is contained in:
parent
69da5bee0c
commit
1bd9fac203
|
|
@ -1,148 +1,150 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import {
|
import {
|
||||||
buildGraphicParamPath,
|
buildGraphicParamPath,
|
||||||
getKeyframeAtTime,
|
coerceAnimationValueForParam,
|
||||||
getParamDefaultInterpolation,
|
getKeyframeAtTime,
|
||||||
getParamValueKind,
|
getParamDefaultInterpolation,
|
||||||
hasKeyframesForPath,
|
getParamValueKind,
|
||||||
upsertPathKeyframe,
|
hasKeyframesForPath,
|
||||||
} from "@/lib/animation";
|
upsertPathKeyframe,
|
||||||
import type {
|
} from "@/lib/animation";
|
||||||
ElementAnimations,
|
import type {
|
||||||
} from "@/lib/animation/types";
|
ElementAnimations,
|
||||||
import type { ParamDefinition } from "@/lib/params";
|
} from "@/lib/animation/types";
|
||||||
import type { TimelineElement } from "@/lib/timeline";
|
import type { ParamDefinition } from "@/lib/params";
|
||||||
|
import type { TimelineElement } from "@/lib/timeline";
|
||||||
export interface KeyframedParamPropertyResult {
|
|
||||||
hasAnimatedKeyframes: boolean;
|
export interface KeyframedParamPropertyResult {
|
||||||
isKeyframedAtTime: boolean;
|
hasAnimatedKeyframes: boolean;
|
||||||
keyframeIdAtTime: string | null;
|
isKeyframedAtTime: boolean;
|
||||||
onPreview: (value: number | string | boolean) => void;
|
keyframeIdAtTime: string | null;
|
||||||
onCommit: () => void;
|
onPreview: (value: number | string | boolean) => void;
|
||||||
toggleKeyframe: () => void;
|
onCommit: () => void;
|
||||||
}
|
toggleKeyframe: () => void;
|
||||||
|
}
|
||||||
export function useKeyframedParamProperty({
|
|
||||||
param,
|
export function useKeyframedParamProperty({
|
||||||
trackId,
|
param,
|
||||||
elementId,
|
trackId,
|
||||||
animations,
|
elementId,
|
||||||
localTime,
|
animations,
|
||||||
isPlayheadWithinElementRange,
|
localTime,
|
||||||
resolvedValue,
|
isPlayheadWithinElementRange,
|
||||||
buildBaseUpdates,
|
resolvedValue,
|
||||||
}: {
|
buildBaseUpdates,
|
||||||
param: ParamDefinition;
|
}: {
|
||||||
trackId: string;
|
param: ParamDefinition;
|
||||||
elementId: string;
|
trackId: string;
|
||||||
animations: ElementAnimations | undefined;
|
elementId: string;
|
||||||
localTime: number;
|
animations: ElementAnimations | undefined;
|
||||||
isPlayheadWithinElementRange: boolean;
|
localTime: number;
|
||||||
resolvedValue: number | string | boolean;
|
isPlayheadWithinElementRange: boolean;
|
||||||
buildBaseUpdates: ({
|
resolvedValue: number | string | boolean;
|
||||||
value,
|
buildBaseUpdates: ({
|
||||||
}: {
|
value,
|
||||||
value: number | string | boolean;
|
}: {
|
||||||
}) => Partial<TimelineElement>;
|
value: number | string | boolean;
|
||||||
}): KeyframedParamPropertyResult {
|
}) => Partial<TimelineElement>;
|
||||||
const editor = useEditor();
|
}): KeyframedParamPropertyResult {
|
||||||
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
|
const editor = useEditor();
|
||||||
const hasAnimatedKeyframes = hasKeyframesForPath({
|
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
|
||||||
animations,
|
const hasAnimatedKeyframes = hasKeyframesForPath({
|
||||||
propertyPath,
|
animations,
|
||||||
});
|
propertyPath,
|
||||||
const keyframeAtTime = isPlayheadWithinElementRange
|
});
|
||||||
? getKeyframeAtTime({
|
const keyframeAtTime = isPlayheadWithinElementRange
|
||||||
animations,
|
? getKeyframeAtTime({
|
||||||
propertyPath,
|
animations,
|
||||||
time: localTime,
|
propertyPath,
|
||||||
})
|
time: localTime,
|
||||||
: null;
|
})
|
||||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
: null;
|
||||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||||
const shouldUseAnimatedChannel =
|
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
const shouldUseAnimatedChannel =
|
||||||
|
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||||
const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
|
|
||||||
if (shouldUseAnimatedChannel) {
|
const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
|
||||||
editor.timeline.previewElements({
|
if (shouldUseAnimatedChannel) {
|
||||||
updates: [
|
editor.timeline.previewElements({
|
||||||
{
|
updates: [
|
||||||
trackId,
|
{
|
||||||
elementId,
|
trackId,
|
||||||
updates: {
|
elementId,
|
||||||
animations: upsertPathKeyframe({
|
updates: {
|
||||||
animations,
|
animations: upsertPathKeyframe({
|
||||||
propertyPath,
|
animations,
|
||||||
time: localTime,
|
propertyPath,
|
||||||
value,
|
time: localTime,
|
||||||
valueKind: getParamValueKind({ param }),
|
value,
|
||||||
defaultInterpolation: getParamDefaultInterpolation({
|
kind: getParamValueKind({ param }),
|
||||||
param,
|
defaultInterpolation: getParamDefaultInterpolation({
|
||||||
}),
|
param,
|
||||||
numericRange:
|
}),
|
||||||
param.type === "number"
|
coerceValue: (nextValue) =>
|
||||||
? { min: param.min, max: param.max, step: param.step }
|
coerceAnimationValueForParam({
|
||||||
: undefined,
|
param,
|
||||||
}),
|
value: nextValue,
|
||||||
},
|
}),
|
||||||
},
|
}),
|
||||||
],
|
},
|
||||||
});
|
},
|
||||||
return;
|
],
|
||||||
}
|
});
|
||||||
|
return;
|
||||||
editor.timeline.previewElements({
|
}
|
||||||
updates: [
|
|
||||||
{
|
editor.timeline.previewElements({
|
||||||
trackId,
|
updates: [
|
||||||
elementId,
|
{
|
||||||
updates: buildBaseUpdates({ value }),
|
trackId,
|
||||||
},
|
elementId,
|
||||||
],
|
updates: buildBaseUpdates({ value }),
|
||||||
});
|
},
|
||||||
};
|
],
|
||||||
|
});
|
||||||
const toggleKeyframe = () => {
|
};
|
||||||
if (!isPlayheadWithinElementRange) {
|
|
||||||
return;
|
const toggleKeyframe = () => {
|
||||||
}
|
if (!isPlayheadWithinElementRange) {
|
||||||
|
return;
|
||||||
if (keyframeIdAtTime) {
|
}
|
||||||
editor.timeline.removeKeyframes({
|
|
||||||
keyframes: [
|
if (keyframeIdAtTime) {
|
||||||
{
|
editor.timeline.removeKeyframes({
|
||||||
trackId,
|
keyframes: [
|
||||||
elementId,
|
{
|
||||||
propertyPath,
|
trackId,
|
||||||
keyframeId: keyframeIdAtTime,
|
elementId,
|
||||||
},
|
propertyPath,
|
||||||
],
|
keyframeId: keyframeIdAtTime,
|
||||||
});
|
},
|
||||||
return;
|
],
|
||||||
}
|
});
|
||||||
|
return;
|
||||||
editor.timeline.upsertKeyframes({
|
}
|
||||||
keyframes: [
|
|
||||||
{
|
editor.timeline.upsertKeyframes({
|
||||||
trackId,
|
keyframes: [
|
||||||
elementId,
|
{
|
||||||
propertyPath,
|
trackId,
|
||||||
time: localTime,
|
elementId,
|
||||||
value: resolvedValue,
|
propertyPath,
|
||||||
},
|
time: localTime,
|
||||||
],
|
value: resolvedValue,
|
||||||
});
|
},
|
||||||
};
|
],
|
||||||
|
});
|
||||||
return {
|
};
|
||||||
hasAnimatedKeyframes,
|
|
||||||
isKeyframedAtTime,
|
return {
|
||||||
keyframeIdAtTime,
|
hasAnimatedKeyframes,
|
||||||
onPreview: previewValue,
|
isKeyframedAtTime,
|
||||||
onCommit: () => editor.timeline.commitPreview(),
|
keyframeIdAtTime,
|
||||||
toggleKeyframe,
|
onPreview: previewValue,
|
||||||
};
|
onCommit: () => editor.timeline.commitPreview(),
|
||||||
}
|
toggleKeyframe,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,28 +315,34 @@ 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;
|
||||||
}) {
|
}) {
|
||||||
|
const button = (
|
||||||
|
<Button
|
||||||
|
variant={isActive ? "secondary" : "text"}
|
||||||
|
size="icon"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick ? (event) => onClick({ event }) : undefined}
|
||||||
|
className={cn(
|
||||||
|
"rounded-sm",
|
||||||
|
disabled ? "cursor-not-allowed opacity-50" : "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip delayDuration={200}>
|
<Tooltip delayDuration={200}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
{buttonWrapper ? buttonWrapper(button) : button}
|
||||||
variant={isActive ? "secondary" : "text"}
|
|
||||||
size="icon"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={(event) => onClick({ event })}
|
|
||||||
className={cn(
|
|
||||||
"rounded-sm",
|
|
||||||
disabled ? "cursor-not-allowed opacity-50" : "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Button>
|
|
||||||
</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,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,73 @@
|
||||||
import type {
|
import type {
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
GraphicParamPath,
|
GraphicParamPath,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import type { ParamValues } from "@/lib/params";
|
import type { ParamValues } from "@/lib/params";
|
||||||
import {
|
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.";
|
|
||||||
|
export function buildGraphicParamPath({
|
||||||
export function buildGraphicParamPath({
|
paramKey,
|
||||||
paramKey,
|
}: {
|
||||||
}: {
|
paramKey: string;
|
||||||
paramKey: string;
|
}): GraphicParamPath {
|
||||||
}): GraphicParamPath {
|
return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
|
||||||
return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
|
}
|
||||||
}
|
|
||||||
|
export function isGraphicParamPath(
|
||||||
export function isGraphicParamPath(
|
propertyPath: string,
|
||||||
propertyPath: string,
|
): propertyPath is GraphicParamPath {
|
||||||
): propertyPath is GraphicParamPath {
|
return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
|
||||||
return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
|
}
|
||||||
}
|
|
||||||
|
export function parseGraphicParamPath({
|
||||||
export function parseGraphicParamPath({
|
propertyPath,
|
||||||
propertyPath,
|
}: {
|
||||||
}: {
|
propertyPath: string;
|
||||||
propertyPath: string;
|
}): { paramKey: string } | null {
|
||||||
}): { paramKey: string } | null {
|
if (!isGraphicParamPath(propertyPath)) {
|
||||||
if (!isGraphicParamPath(propertyPath)) {
|
return null;
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
|
||||||
const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
|
return paramKey.length > 0 ? { paramKey } : null;
|
||||||
return paramKey.length > 0 ? { paramKey } : null;
|
}
|
||||||
}
|
|
||||||
|
export function resolveGraphicParamsAtTime({
|
||||||
export function resolveGraphicParamsAtTime({
|
element,
|
||||||
element,
|
localTime,
|
||||||
localTime,
|
}: {
|
||||||
}: {
|
element: {
|
||||||
element: {
|
definitionId: string;
|
||||||
definitionId: string;
|
params: ParamValues;
|
||||||
params: ParamValues;
|
animations?: ElementAnimations;
|
||||||
animations?: ElementAnimations;
|
};
|
||||||
};
|
localTime: number;
|
||||||
localTime: number;
|
}): ParamValues {
|
||||||
}): ParamValues {
|
const definition = getGraphicDefinition({
|
||||||
const definition = getGraphicDefinition({
|
definitionId: element.definitionId,
|
||||||
definitionId: element.definitionId,
|
});
|
||||||
});
|
const baseParams = resolveGraphicParams(definition, element.params);
|
||||||
const baseParams = resolveGraphicParams(definition, element.params);
|
const resolved: ParamValues = { ...baseParams };
|
||||||
const resolved: ParamValues = { ...baseParams };
|
|
||||||
|
for (const param of definition.params) {
|
||||||
for (const param of definition.params) {
|
const path = buildGraphicParamPath({ paramKey: param.key });
|
||||||
const path = buildGraphicParamPath({ paramKey: param.key });
|
if (!element.animations?.bindings[path]) {
|
||||||
const channel = getChannel({
|
continue;
|
||||||
animations: element.animations,
|
}
|
||||||
propertyPath: path,
|
|
||||||
});
|
resolved[param.key] = resolveAnimationPathValueAtTime({
|
||||||
if (!channel || channel.keyframes.length === 0) {
|
animations: element.animations,
|
||||||
continue;
|
propertyPath: path,
|
||||||
}
|
localTime: Math.max(0, localTime),
|
||||||
|
fallbackValue: baseParams[param.key] ?? param.default,
|
||||||
resolved[param.key] = getChannelValueAtTime({
|
});
|
||||||
channel,
|
}
|
||||||
time: Math.max(0, localTime),
|
|
||||||
fallbackValue: baseParams[param.key] ?? param.default,
|
return resolved;
|
||||||
}) as number | string | boolean;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,81 @@
|
||||||
export {
|
export {
|
||||||
getChannelValueAtTime,
|
getChannelValueAtTime,
|
||||||
getNumberChannelValueAtTime,
|
getDiscreteChannelValueAtTime,
|
||||||
getVectorChannelValueAtTime,
|
getScalarChannelValueAtTime,
|
||||||
normalizeChannel,
|
getScalarSegmentInterpolation,
|
||||||
} from "./interpolation";
|
normalizeChannel,
|
||||||
|
} from "./interpolation";
|
||||||
export {
|
|
||||||
clampAnimationsToDuration,
|
export {
|
||||||
cloneAnimations,
|
clampAnimationsToDuration,
|
||||||
getChannel,
|
cloneAnimations,
|
||||||
removeElementKeyframe,
|
getChannel,
|
||||||
retimeElementKeyframe,
|
removeElementKeyframe,
|
||||||
setChannel,
|
retimeElementKeyframe,
|
||||||
splitAnimationsAtTime,
|
setChannel,
|
||||||
upsertElementKeyframe,
|
splitAnimationsAtTime,
|
||||||
upsertPathKeyframe,
|
upsertElementKeyframe,
|
||||||
} from "./keyframes";
|
upsertPathKeyframe,
|
||||||
|
} from "./keyframes";
|
||||||
export {
|
|
||||||
getElementLocalTime,
|
export {
|
||||||
resolveColorAtTime,
|
getElementLocalTime,
|
||||||
resolveNumberAtTime,
|
resolveAnimationPathValueAtTime,
|
||||||
resolveOpacityAtTime,
|
resolveColorAtTime,
|
||||||
resolveTransformAtTime,
|
resolveNumberAtTime,
|
||||||
} from "./resolve";
|
resolveOpacityAtTime,
|
||||||
|
resolveTransformAtTime,
|
||||||
export {
|
} from "./resolve";
|
||||||
coerceAnimationValueForProperty,
|
|
||||||
getAnimationPropertyDefinition,
|
export {
|
||||||
getDefaultInterpolationForProperty,
|
coerceAnimationValueForProperty,
|
||||||
getElementBaseValueForProperty,
|
getAnimationPropertyDefinition,
|
||||||
isAnimationPropertyPath,
|
getDefaultInterpolationForProperty,
|
||||||
supportsAnimationProperty,
|
getElementBaseValueForProperty,
|
||||||
type AnimationPropertyDefinition,
|
isAnimationPropertyPath,
|
||||||
type NumericSpec,
|
supportsAnimationProperty,
|
||||||
type NumericRange,
|
type AnimationPropertyDefinition,
|
||||||
withElementBaseValueForProperty,
|
type NumericSpec,
|
||||||
} from "./property-registry";
|
withElementBaseValueForProperty,
|
||||||
|
} from "./property-registry";
|
||||||
export {
|
|
||||||
getElementKeyframes,
|
export {
|
||||||
getKeyframeAtTime,
|
getElementKeyframes,
|
||||||
hasKeyframesForPath,
|
getKeyframeById,
|
||||||
} from "./keyframe-query";
|
getKeyframeAtTime,
|
||||||
|
hasKeyframesForPath,
|
||||||
export {
|
} from "./keyframe-query";
|
||||||
buildGraphicParamPath,
|
|
||||||
isGraphicParamPath,
|
export {
|
||||||
parseGraphicParamPath,
|
buildGraphicParamPath,
|
||||||
resolveGraphicParamsAtTime,
|
isGraphicParamPath,
|
||||||
} from "./graphic-param-channel";
|
parseGraphicParamPath,
|
||||||
|
resolveGraphicParamsAtTime,
|
||||||
export {
|
} from "./graphic-param-channel";
|
||||||
isAnimationPath,
|
|
||||||
resolveAnimationTarget,
|
export {
|
||||||
getParamValueKind,
|
buildEffectParamPath,
|
||||||
getParamDefaultInterpolation,
|
isEffectParamPath,
|
||||||
type AnimationPathDescriptor,
|
parseEffectParamPath,
|
||||||
} from "./target-resolver";
|
removeEffectParamKeyframe,
|
||||||
|
resolveEffectParamsAtTime,
|
||||||
export {
|
} from "./effect-param-channel";
|
||||||
getGroupKeyframesAtTime,
|
|
||||||
hasGroupKeyframeAtTime,
|
export {
|
||||||
type GroupKeyframeRef,
|
isAnimationPath,
|
||||||
} from "./property-groups";
|
coerceAnimationValueForParam,
|
||||||
|
resolveAnimationTarget,
|
||||||
export {
|
getParamValueKind,
|
||||||
getVectorChannelForPath,
|
getParamDefaultInterpolation,
|
||||||
isVectorValue,
|
type AnimationPathDescriptor,
|
||||||
} from "./vector-channel";
|
} from "./target-resolver";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getGroupKeyframesAtTime,
|
||||||
|
hasGroupKeyframeAtTime,
|
||||||
|
type GroupKeyframeRef,
|
||||||
|
} from "./property-groups";
|
||||||
|
|
||||||
|
export {
|
||||||
|
isVectorValue,
|
||||||
|
} 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 (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
||||||
if (!firstKeyframe || !lastKeyframe) {
|
return edgeKey.value;
|
||||||
return fallbackValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) {
|
return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value);
|
||||||
return firstKeyframe.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (time >= lastKeyframe.time - TIME_EPSILON_SECONDS) {
|
|
||||||
return lastKeyframe.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (
|
|
||||||
let keyframeIndex = 0;
|
|
||||||
keyframeIndex < keyframes.length - 1;
|
|
||||||
keyframeIndex++
|
|
||||||
) {
|
|
||||||
const leftKeyframe = keyframes[keyframeIndex];
|
|
||||||
const rightKeyframe = keyframes[keyframeIndex + 1];
|
|
||||||
|
|
||||||
if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) {
|
|
||||||
return rightKeyframe.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isBetweenPair = isWithinTimePair({
|
|
||||||
time,
|
|
||||||
leftTime: leftKeyframe.time,
|
|
||||||
rightTime: rightKeyframe.time,
|
|
||||||
});
|
|
||||||
if (!isBetweenPair) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const span = rightKeyframe.time - leftKeyframe.time;
|
|
||||||
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
|
||||||
return rightKeyframe.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const progress = clamp01({
|
|
||||||
value: (time - leftKeyframe.time) / span,
|
|
||||||
});
|
|
||||||
|
|
||||||
return getInterpolatedValue({
|
|
||||||
leftKeyframe,
|
|
||||||
rightKeyframe,
|
|
||||||
progress,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return lastKeyframe.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
const curveProgress = solveBezierProgressForTime({
|
||||||
|
time,
|
||||||
|
leftKey,
|
||||||
|
rightKey,
|
||||||
|
});
|
||||||
|
const rightHandle =
|
||||||
|
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
|
||||||
|
const leftHandle =
|
||||||
|
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
|
||||||
|
return getBezierPoint({
|
||||||
|
progress: curveProgress,
|
||||||
|
p0: leftKey.value,
|
||||||
|
p1: leftKey.value + rightHandle.dv,
|
||||||
|
p2: rightKey.value + leftHandle.dv,
|
||||||
|
p3: rightKey.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastKey.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getColorValueAtTime({
|
export function getDiscreteChannelValueAtTime({
|
||||||
channel,
|
|
||||||
time,
|
|
||||||
fallbackValue,
|
|
||||||
}: {
|
|
||||||
channel: ColorAnimationChannel | undefined;
|
|
||||||
time: number;
|
|
||||||
fallbackValue: string;
|
|
||||||
}): string {
|
|
||||||
return evaluateChannelValueAtTime({
|
|
||||||
keyframes: channel?.keyframes,
|
|
||||||
time,
|
|
||||||
fallbackValue,
|
|
||||||
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
|
|
||||||
if (leftKeyframe.interpolation === "hold") {
|
|
||||||
return leftKeyframe.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return interpolateColor({
|
|
||||||
leftColor: leftKeyframe.value,
|
|
||||||
rightColor: rightKeyframe.value,
|
|
||||||
progress,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getVectorChannelValueAtTime({
|
|
||||||
channel,
|
|
||||||
time,
|
|
||||||
fallbackValue,
|
|
||||||
}: {
|
|
||||||
channel: VectorAnimationChannel | undefined;
|
|
||||||
time: number;
|
|
||||||
fallbackValue: VectorValue;
|
|
||||||
}): VectorValue {
|
|
||||||
return evaluateChannelValueAtTime({
|
|
||||||
keyframes: channel?.keyframes,
|
|
||||||
time,
|
|
||||||
fallbackValue,
|
|
||||||
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
|
|
||||||
if (leftKeyframe.interpolation === "hold") {
|
|
||||||
return leftKeyframe.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
x:
|
|
||||||
leftKeyframe.value.x +
|
|
||||||
(rightKeyframe.value.x - leftKeyframe.value.x) * progress,
|
|
||||||
y:
|
|
||||||
leftKeyframe.value.y +
|
|
||||||
(rightKeyframe.value.y - leftKeyframe.value.y) * progress,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDiscreteValueAtTime({
|
|
||||||
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({
|
||||||
}
|
channel,
|
||||||
|
time,
|
||||||
return getNumberChannelValueAtTime({
|
fallbackValue,
|
||||||
channel,
|
})
|
||||||
time,
|
: 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,72 +1,298 @@
|
||||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||||
import type {
|
import type {
|
||||||
AnimationPath,
|
AnimationBindingInstance,
|
||||||
ElementAnimations,
|
AnimationChannel,
|
||||||
ElementKeyframe,
|
AnimationPath,
|
||||||
} from "@/lib/animation/types";
|
ElementAnimations,
|
||||||
import { isAnimationPath } from "./target-resolver";
|
ElementKeyframe,
|
||||||
|
} from "@/lib/animation/types";
|
||||||
export function getElementKeyframes({
|
import {
|
||||||
animations,
|
type AnimationComponentValue,
|
||||||
}: {
|
composeAnimationValue,
|
||||||
animations: ElementAnimations | undefined;
|
} from "./binding-values";
|
||||||
}): ElementKeyframe[] {
|
import {
|
||||||
if (!animations) {
|
getChannelValueAtTime,
|
||||||
return [];
|
getScalarSegmentInterpolation,
|
||||||
}
|
} from "./interpolation";
|
||||||
|
import { isAnimationPath } from "./target-resolver";
|
||||||
return Object.entries(animations.channels).flatMap(
|
|
||||||
([propertyPath, channel]) => {
|
function getBindingFallbackValue({
|
||||||
if (
|
channel,
|
||||||
!channel ||
|
}: {
|
||||||
channel.keyframes.length === 0 ||
|
channel: ElementAnimations["channels"][string];
|
||||||
!isAnimationPath(propertyPath)
|
}) {
|
||||||
) {
|
if (!channel || channel.keys.length === 0) {
|
||||||
return [];
|
return channel?.kind === "discrete" ? false : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return channel.keyframes.map((keyframe) => ({
|
return channel.keys[0].value;
|
||||||
propertyPath,
|
}
|
||||||
id: keyframe.id,
|
|
||||||
time: keyframe.time,
|
interface BindingKeyframeMatch {
|
||||||
value: keyframe.value,
|
componentIndex: number;
|
||||||
interpolation: keyframe.interpolation,
|
channel: AnimationChannel;
|
||||||
}));
|
keyframe: AnimationChannel["keys"][number];
|
||||||
},
|
}
|
||||||
);
|
|
||||||
}
|
function getBindingKeyframeMatches({
|
||||||
|
animations,
|
||||||
export function hasKeyframesForPath({
|
binding,
|
||||||
animations,
|
}: {
|
||||||
propertyPath,
|
animations: ElementAnimations;
|
||||||
}: {
|
binding: AnimationBindingInstance;
|
||||||
animations: ElementAnimations | undefined;
|
}): BindingKeyframeMatch[] {
|
||||||
propertyPath: AnimationPath;
|
return binding.components.flatMap((component, componentIndex) => {
|
||||||
}): boolean {
|
const channel = animations.channels[component.channelId];
|
||||||
const channel = animations?.channels[propertyPath];
|
if (!channel || channel.keys.length === 0) {
|
||||||
return Boolean(channel && channel.keyframes.length > 0);
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getKeyframeAtTime({
|
return channel.keys.map((keyframe) => ({
|
||||||
animations,
|
componentIndex,
|
||||||
propertyPath,
|
channel,
|
||||||
time,
|
keyframe,
|
||||||
}: {
|
}));
|
||||||
animations: ElementAnimations | undefined;
|
});
|
||||||
propertyPath: AnimationPath;
|
}
|
||||||
time: number;
|
|
||||||
}): ElementKeyframe | null {
|
function getUniqueBindingKeyframeMatches({
|
||||||
const channel = animations?.channels[propertyPath];
|
animations,
|
||||||
if (!channel || channel.keyframes.length === 0) return null;
|
binding,
|
||||||
const keyframe = channel.keyframes.find(
|
}: {
|
||||||
(keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
|
animations: ElementAnimations;
|
||||||
);
|
binding: AnimationBindingInstance;
|
||||||
if (!keyframe) return null;
|
}): BindingKeyframeMatch[] {
|
||||||
return {
|
const sortedMatches = getBindingKeyframeMatches({
|
||||||
propertyPath,
|
animations,
|
||||||
id: keyframe.id,
|
binding,
|
||||||
time: keyframe.time,
|
}).sort(
|
||||||
value: keyframe.value,
|
(leftMatch, rightMatch) =>
|
||||||
interpolation: keyframe.interpolation,
|
leftMatch.keyframe.time - rightMatch.keyframe.time ||
|
||||||
};
|
leftMatch.componentIndex - rightMatch.componentIndex,
|
||||||
}
|
);
|
||||||
|
const uniqueMatches: BindingKeyframeMatch[] = [];
|
||||||
|
|
||||||
|
for (const match of sortedMatches) {
|
||||||
|
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
|
||||||
|
if (
|
||||||
|
!previousMatch ||
|
||||||
|
Math.abs(previousMatch.keyframe.time - match.keyframe.time) >
|
||||||
|
TIME_EPSILON_SECONDS
|
||||||
|
) {
|
||||||
|
uniqueMatches.push(match);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
previousMatch.componentIndex !== 0 &&
|
||||||
|
match.componentIndex === 0
|
||||||
|
) {
|
||||||
|
uniqueMatches[uniqueMatches.length - 1] = match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferredBindingKeyframeMatch({
|
||||||
|
matches,
|
||||||
|
}: {
|
||||||
|
matches: BindingKeyframeMatch[];
|
||||||
|
}): BindingKeyframeMatch | null {
|
||||||
|
return (
|
||||||
|
matches.find((match) => match.componentIndex === 0) ??
|
||||||
|
matches[0] ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getComposedBindingValueAtTime({
|
||||||
|
animations,
|
||||||
|
binding,
|
||||||
|
time,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations;
|
||||||
|
binding: AnimationBindingInstance;
|
||||||
|
time: number;
|
||||||
|
}) {
|
||||||
|
const componentValues = Object.fromEntries(
|
||||||
|
binding.components.map((component) => {
|
||||||
|
const channel = animations.channels[component.channelId];
|
||||||
|
return [
|
||||||
|
component.key,
|
||||||
|
getChannelValueAtTime({
|
||||||
|
channel,
|
||||||
|
time,
|
||||||
|
fallbackValue: getBindingFallbackValue({ channel }),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
) as Record<string, AnimationComponentValue | undefined>;
|
||||||
|
|
||||||
|
return composeAnimationValue({
|
||||||
|
binding,
|
||||||
|
componentValues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKeyframeInterpolation({
|
||||||
|
channel,
|
||||||
|
keyframe,
|
||||||
|
}: {
|
||||||
|
channel: AnimationChannel;
|
||||||
|
keyframe: AnimationChannel["keys"][number];
|
||||||
|
}) {
|
||||||
|
return channel.kind === "scalar" && "segmentToNext" in keyframe
|
||||||
|
? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
|
||||||
|
: "hold";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toElementKeyframe({
|
||||||
|
animations,
|
||||||
|
binding,
|
||||||
|
propertyPath,
|
||||||
|
keyframeMatch,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations;
|
||||||
|
binding: AnimationBindingInstance;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
keyframeMatch: BindingKeyframeMatch;
|
||||||
|
}): ElementKeyframe | null {
|
||||||
|
const value = getComposedBindingValueAtTime({
|
||||||
|
animations,
|
||||||
|
binding,
|
||||||
|
time: keyframeMatch.keyframe.time,
|
||||||
|
});
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
propertyPath,
|
||||||
|
id: keyframeMatch.keyframe.id,
|
||||||
|
time: keyframeMatch.keyframe.time,
|
||||||
|
value,
|
||||||
|
interpolation: getKeyframeInterpolation({
|
||||||
|
channel: keyframeMatch.channel,
|
||||||
|
keyframe: keyframeMatch.keyframe,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElementKeyframes({
|
||||||
|
animations,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
}): ElementKeyframe[] {
|
||||||
|
if (!animations) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(animations.bindings).flatMap(
|
||||||
|
([propertyPath, binding]) => {
|
||||||
|
if (!binding || !isAnimationPath(propertyPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return getUniqueBindingKeyframeMatches({
|
||||||
|
animations,
|
||||||
|
binding,
|
||||||
|
}).flatMap((keyframeMatch) => {
|
||||||
|
const keyframe = toElementKeyframe({
|
||||||
|
animations,
|
||||||
|
binding,
|
||||||
|
propertyPath,
|
||||||
|
keyframeMatch,
|
||||||
|
});
|
||||||
|
if (!keyframe) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [keyframe];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasKeyframesForPath({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
}): boolean {
|
||||||
|
const binding = animations?.bindings[propertyPath];
|
||||||
|
if (!binding) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return binding.components.some((component) =>
|
||||||
|
Boolean(animations?.channels[component.channelId]?.keys.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKeyframeAtTime({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
time,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
time: number;
|
||||||
|
}): ElementKeyframe | null {
|
||||||
|
const binding = animations?.bindings[propertyPath];
|
||||||
|
if (!binding) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyframeMatch = getPreferredBindingKeyframeMatch({
|
||||||
|
matches: getBindingKeyframeMatches({
|
||||||
|
animations,
|
||||||
|
binding,
|
||||||
|
}).filter(({ keyframe }) =>
|
||||||
|
Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!keyframeMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toElementKeyframe({
|
||||||
|
animations,
|
||||||
|
binding,
|
||||||
|
propertyPath,
|
||||||
|
keyframeMatch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKeyframeById({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
keyframeId,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
keyframeId: string;
|
||||||
|
}): ElementKeyframe | null {
|
||||||
|
const binding = animations?.bindings[propertyPath];
|
||||||
|
if (!binding) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyframeMatch = getPreferredBindingKeyframeMatch({
|
||||||
|
matches: getBindingKeyframeMatches({
|
||||||
|
animations,
|
||||||
|
binding,
|
||||||
|
}).filter(({ keyframe }) => keyframe.id === keyframeId),
|
||||||
|
});
|
||||||
|
if (!keyframeMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toElementKeyframe({
|
||||||
|
animations,
|
||||||
|
binding,
|
||||||
|
propertyPath,
|
||||||
|
keyframeMatch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
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,369 +1,377 @@
|
||||||
import type {
|
import type {
|
||||||
AnimationInterpolation,
|
AnimationBindingKind,
|
||||||
AnimationPropertyPath,
|
AnimationInterpolation,
|
||||||
AnimationValue,
|
AnimationPropertyPath,
|
||||||
AnimationValueKind,
|
AnimationValue,
|
||||||
DiscreteValue,
|
VectorValue,
|
||||||
VectorValue,
|
} from "@/lib/animation/types";
|
||||||
} from "@/lib/animation/types";
|
import { isVectorValue, parseColorToLinearRgba } from "./binding-values";
|
||||||
import { isVectorValue } from "./vector-channel";
|
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 {
|
CORNER_RADIUS_MAX,
|
||||||
CORNER_RADIUS_MAX,
|
CORNER_RADIUS_MIN,
|
||||||
CORNER_RADIUS_MIN,
|
} from "@/constants/text-constants";
|
||||||
} from "@/constants/text-constants";
|
import {
|
||||||
import {
|
canElementHaveAudio,
|
||||||
canElementHaveAudio,
|
isVisualElement,
|
||||||
isVisualElement,
|
} from "@/lib/timeline/element-utils";
|
||||||
} from "@/lib/timeline/element-utils";
|
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
||||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
import { snapToStep } from "@/utils/math";
|
||||||
import { snapToStep } from "@/utils/math";
|
|
||||||
|
export interface NumericSpec {
|
||||||
export interface NumericSpec {
|
min?: number;
|
||||||
min?: number;
|
max?: number;
|
||||||
max?: number;
|
step?: number;
|
||||||
step?: number;
|
}
|
||||||
}
|
|
||||||
|
export interface AnimationPropertyDefinition {
|
||||||
export type NumericRange = NumericSpec;
|
kind: AnimationBindingKind;
|
||||||
|
defaultInterpolation: AnimationInterpolation;
|
||||||
export interface AnimationPropertyDefinition {
|
numericRanges?: Partial<Record<string, NumericSpec>>;
|
||||||
valueKind: AnimationValueKind;
|
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
||||||
defaultInterpolation: AnimationInterpolation;
|
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
|
||||||
numericRange?: NumericSpec;
|
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
|
||||||
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
setValue: ({
|
||||||
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
|
element,
|
||||||
setValue: ({
|
value,
|
||||||
element,
|
}: {
|
||||||
value,
|
element: TimelineElement;
|
||||||
}: {
|
value: AnimationValue;
|
||||||
element: TimelineElement;
|
}) => TimelineElement;
|
||||||
value: AnimationValue;
|
}
|
||||||
}) => TimelineElement;
|
|
||||||
}
|
function applyNumericSpec({
|
||||||
|
value,
|
||||||
const ANIMATION_PROPERTY_REGISTRY: Record<
|
numericRange,
|
||||||
AnimationPropertyPath,
|
}: {
|
||||||
AnimationPropertyDefinition
|
value: number;
|
||||||
> = {
|
numericRange: NumericSpec | undefined;
|
||||||
"transform.position": {
|
}): number {
|
||||||
valueKind: "vector",
|
if (!numericRange) {
|
||||||
defaultInterpolation: "linear",
|
return value;
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
}
|
||||||
getValue: ({ element }) =>
|
|
||||||
isVisualElement(element) ? element.transform.position : null,
|
const steppedValue =
|
||||||
setValue: ({ element, value }) =>
|
numericRange.step != null
|
||||||
isVisualElement(element)
|
? snapToStep({ value, step: numericRange.step })
|
||||||
? {
|
: value;
|
||||||
...element,
|
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
|
||||||
transform: {
|
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
|
||||||
...element.transform,
|
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||||
position: value as VectorValue,
|
}
|
||||||
},
|
|
||||||
}
|
function coerceNumberValue({
|
||||||
: element,
|
value,
|
||||||
},
|
numericRange,
|
||||||
"transform.scaleX": {
|
}: {
|
||||||
valueKind: "number",
|
value: AnimationValue;
|
||||||
defaultInterpolation: "linear",
|
numericRange?: NumericSpec;
|
||||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
}): number | null {
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||||
getValue: ({ element }) =>
|
return null;
|
||||||
isVisualElement(element) ? element.transform.scaleX : null,
|
}
|
||||||
setValue: ({ element, value }) =>
|
|
||||||
isVisualElement(element)
|
return applyNumericSpec({ value, numericRange });
|
||||||
? {
|
}
|
||||||
...element,
|
|
||||||
transform: { ...element.transform, scaleX: value as number },
|
function coerceColorValue({
|
||||||
}
|
value,
|
||||||
: element,
|
}: {
|
||||||
},
|
value: AnimationValue;
|
||||||
"transform.scaleY": {
|
}): string | null {
|
||||||
valueKind: "number",
|
return typeof value === "string" && parseColorToLinearRgba({ color: value })
|
||||||
defaultInterpolation: "linear",
|
? value
|
||||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
: null;
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
}
|
||||||
getValue: ({ element }) =>
|
|
||||||
isVisualElement(element) ? element.transform.scaleY : null,
|
function createNumberPropertyDefinition({
|
||||||
setValue: ({ element, value }) =>
|
numericRange,
|
||||||
isVisualElement(element)
|
supportsElement,
|
||||||
? {
|
getValue,
|
||||||
...element,
|
setValue,
|
||||||
transform: { ...element.transform, scaleY: value as number },
|
}: {
|
||||||
}
|
numericRange?: NumericSpec;
|
||||||
: element,
|
supportsElement: AnimationPropertyDefinition["supportsElement"];
|
||||||
},
|
getValue: AnimationPropertyDefinition["getValue"];
|
||||||
"transform.rotate": {
|
setValue: AnimationPropertyDefinition["setValue"];
|
||||||
valueKind: "number",
|
}): AnimationPropertyDefinition {
|
||||||
defaultInterpolation: "linear",
|
return {
|
||||||
numericRange: { min: -360, max: 360, step: 1 },
|
kind: "number",
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
defaultInterpolation: "linear",
|
||||||
getValue: ({ element }) =>
|
numericRanges: numericRange ? { value: numericRange } : undefined,
|
||||||
isVisualElement(element) ? element.transform.rotate : null,
|
supportsElement,
|
||||||
setValue: ({ element, value }) =>
|
getValue,
|
||||||
isVisualElement(element)
|
coerceValue: ({ value }) =>
|
||||||
? {
|
coerceNumberValue({
|
||||||
...element,
|
value,
|
||||||
transform: { ...element.transform, rotate: value as number },
|
numericRange,
|
||||||
}
|
}),
|
||||||
: element,
|
setValue,
|
||||||
},
|
};
|
||||||
opacity: {
|
}
|
||||||
valueKind: "number",
|
|
||||||
defaultInterpolation: "linear",
|
const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||||
numericRange: { min: 0, max: 1, step: 0.01 },
|
AnimationPropertyPath,
|
||||||
supportsElement: ({ element }) => isVisualElement(element),
|
AnimationPropertyDefinition
|
||||||
getValue: ({ element }) =>
|
> = {
|
||||||
isVisualElement(element) ? element.opacity : null,
|
"transform.position": {
|
||||||
setValue: ({ element, value }) =>
|
kind: "vector2",
|
||||||
isVisualElement(element)
|
defaultInterpolation: "linear",
|
||||||
? { ...element, opacity: value as number }
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
: element,
|
getValue: ({ element }) =>
|
||||||
},
|
isVisualElement(element) ? element.transform.position : null,
|
||||||
volume: {
|
coerceValue: ({ value }) => (isVectorValue(value) ? value : null),
|
||||||
valueKind: "number",
|
setValue: ({ element, value }) =>
|
||||||
defaultInterpolation: "linear",
|
isVisualElement(element)
|
||||||
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
|
? {
|
||||||
supportsElement: ({ element }) => canElementHaveAudio(element),
|
...element,
|
||||||
getValue: ({ element }) =>
|
transform: {
|
||||||
canElementHaveAudio(element) ? element.volume ?? 0 : null,
|
...element.transform,
|
||||||
setValue: ({ element, value }) =>
|
position: value as VectorValue,
|
||||||
canElementHaveAudio(element)
|
},
|
||||||
? { ...element, volume: value as number }
|
}
|
||||||
: element,
|
: element,
|
||||||
},
|
},
|
||||||
color: {
|
"transform.scaleX": createNumberPropertyDefinition({
|
||||||
valueKind: "color",
|
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||||
defaultInterpolation: "linear",
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
getValue: ({ element }) =>
|
||||||
getValue: ({ element }) => (element.type === "text" ? element.color : null),
|
isVisualElement(element) ? element.transform.scaleX : null,
|
||||||
setValue: ({ element, value }) =>
|
setValue: ({ element, value }) =>
|
||||||
element.type === "text"
|
isVisualElement(element)
|
||||||
? { ...element, color: value as string }
|
? {
|
||||||
: element,
|
...element,
|
||||||
},
|
transform: { ...element.transform, scaleX: value as number },
|
||||||
"background.color": {
|
}
|
||||||
valueKind: "color",
|
: element,
|
||||||
defaultInterpolation: "linear",
|
}),
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
"transform.scaleY": createNumberPropertyDefinition({
|
||||||
getValue: ({ element }) =>
|
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||||
element.type === "text" ? element.background.color : null,
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
setValue: ({ element, value }) =>
|
getValue: ({ element }) =>
|
||||||
element.type === "text"
|
isVisualElement(element) ? element.transform.scaleY : null,
|
||||||
? {
|
setValue: ({ element, value }) =>
|
||||||
...element,
|
isVisualElement(element)
|
||||||
background: { ...element.background, color: value as string },
|
? {
|
||||||
}
|
...element,
|
||||||
: element,
|
transform: { ...element.transform, scaleY: value as number },
|
||||||
},
|
}
|
||||||
"background.paddingX": {
|
: element,
|
||||||
valueKind: "number",
|
}),
|
||||||
defaultInterpolation: "linear",
|
"transform.rotate": createNumberPropertyDefinition({
|
||||||
numericRange: { min: 0, step: 1 },
|
numericRange: { min: -360, max: 360, step: 1 },
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) =>
|
||||||
element.type === "text"
|
isVisualElement(element) ? element.transform.rotate : null,
|
||||||
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
|
setValue: ({ element, value }) =>
|
||||||
: null,
|
isVisualElement(element)
|
||||||
setValue: ({ element, value }) =>
|
? {
|
||||||
element.type === "text"
|
...element,
|
||||||
? {
|
transform: { ...element.transform, rotate: value as number },
|
||||||
...element,
|
}
|
||||||
background: { ...element.background, paddingX: value as number },
|
: element,
|
||||||
}
|
}),
|
||||||
: element,
|
opacity: createNumberPropertyDefinition({
|
||||||
},
|
numericRange: { min: 0, max: 1, step: 0.01 },
|
||||||
"background.paddingY": {
|
supportsElement: ({ element }) => isVisualElement(element),
|
||||||
valueKind: "number",
|
getValue: ({ element }) =>
|
||||||
defaultInterpolation: "linear",
|
isVisualElement(element) ? element.opacity : null,
|
||||||
numericRange: { min: 0, step: 1 },
|
setValue: ({ element, value }) =>
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
isVisualElement(element)
|
||||||
getValue: ({ element }) =>
|
? { ...element, opacity: value as number }
|
||||||
element.type === "text"
|
: element,
|
||||||
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
|
}),
|
||||||
: null,
|
volume: createNumberPropertyDefinition({
|
||||||
setValue: ({ element, value }) =>
|
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
|
||||||
element.type === "text"
|
supportsElement: ({ element }) => canElementHaveAudio(element),
|
||||||
? {
|
getValue: ({ element }) =>
|
||||||
...element,
|
canElementHaveAudio(element) ? element.volume ?? 0 : null,
|
||||||
background: { ...element.background, paddingY: value as number },
|
setValue: ({ element, value }) =>
|
||||||
}
|
canElementHaveAudio(element)
|
||||||
: element,
|
? { ...element, volume: value as number }
|
||||||
},
|
: element,
|
||||||
"background.offsetX": {
|
}),
|
||||||
valueKind: "number",
|
color: {
|
||||||
defaultInterpolation: "linear",
|
kind: "color",
|
||||||
numericRange: { step: 1 },
|
defaultInterpolation: "linear",
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
getValue: ({ element }) =>
|
getValue: ({ element }) => (element.type === "text" ? element.color : null),
|
||||||
element.type === "text"
|
coerceValue: ({ value }) => coerceColorValue({ value }),
|
||||||
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
|
setValue: ({ element, value }) =>
|
||||||
: null,
|
element.type === "text"
|
||||||
setValue: ({ element, value }) =>
|
? { ...element, color: value as string }
|
||||||
element.type === "text"
|
: element,
|
||||||
? {
|
},
|
||||||
...element,
|
"background.color": {
|
||||||
background: { ...element.background, offsetX: value as number },
|
kind: "color",
|
||||||
}
|
defaultInterpolation: "linear",
|
||||||
: element,
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
},
|
getValue: ({ element }) =>
|
||||||
"background.offsetY": {
|
element.type === "text" ? element.background.color : null,
|
||||||
valueKind: "number",
|
coerceValue: ({ value }) => coerceColorValue({ value }),
|
||||||
defaultInterpolation: "linear",
|
setValue: ({ element, value }) =>
|
||||||
numericRange: { step: 1 },
|
element.type === "text"
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
? {
|
||||||
getValue: ({ element }) =>
|
...element,
|
||||||
element.type === "text"
|
background: { ...element.background, color: value as string },
|
||||||
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
|
}
|
||||||
: null,
|
: element,
|
||||||
setValue: ({ element, value }) =>
|
},
|
||||||
element.type === "text"
|
"background.paddingX": createNumberPropertyDefinition({
|
||||||
? {
|
numericRange: { min: 0, step: 1 },
|
||||||
...element,
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
background: { ...element.background, offsetY: value as number },
|
getValue: ({ element }) =>
|
||||||
}
|
element.type === "text"
|
||||||
: element,
|
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
|
||||||
},
|
: null,
|
||||||
"background.cornerRadius": {
|
setValue: ({ element, value }) =>
|
||||||
valueKind: "number",
|
element.type === "text"
|
||||||
defaultInterpolation: "linear",
|
? {
|
||||||
numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 },
|
...element,
|
||||||
supportsElement: ({ element }) => element.type === "text",
|
background: { ...element.background, paddingX: value as number },
|
||||||
getValue: ({ element }) =>
|
}
|
||||||
element.type === "text"
|
: element,
|
||||||
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
|
}),
|
||||||
: null,
|
"background.paddingY": createNumberPropertyDefinition({
|
||||||
setValue: ({ element, value }) =>
|
numericRange: { min: 0, step: 1 },
|
||||||
element.type === "text"
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
? {
|
getValue: ({ element }) =>
|
||||||
...element,
|
element.type === "text"
|
||||||
background: { ...element.background, cornerRadius: value as number },
|
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
|
||||||
}
|
: null,
|
||||||
: element,
|
setValue: ({ element, value }) =>
|
||||||
},
|
element.type === "text"
|
||||||
};
|
? {
|
||||||
|
...element,
|
||||||
export function isAnimationPropertyPath(
|
background: { ...element.background, paddingY: value as number },
|
||||||
propertyPath: string,
|
}
|
||||||
): propertyPath is AnimationPropertyPath {
|
: element,
|
||||||
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
|
}),
|
||||||
}
|
"background.offsetX": createNumberPropertyDefinition({
|
||||||
|
numericRange: { step: 1 },
|
||||||
export function getAnimationPropertyDefinition({
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
propertyPath,
|
getValue: ({ element }) =>
|
||||||
}: {
|
element.type === "text"
|
||||||
propertyPath: AnimationPropertyPath;
|
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
|
||||||
}): AnimationPropertyDefinition {
|
: null,
|
||||||
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
|
setValue: ({ element, value }) =>
|
||||||
}
|
element.type === "text"
|
||||||
|
? {
|
||||||
export function supportsAnimationProperty({
|
...element,
|
||||||
element,
|
background: { ...element.background, offsetX: value as number },
|
||||||
propertyPath,
|
}
|
||||||
}: {
|
: element,
|
||||||
element: TimelineElement;
|
}),
|
||||||
propertyPath: AnimationPropertyPath;
|
"background.offsetY": createNumberPropertyDefinition({
|
||||||
}): boolean {
|
numericRange: { step: 1 },
|
||||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
return propertyDefinition.supportsElement({ element });
|
getValue: ({ element }) =>
|
||||||
}
|
element.type === "text"
|
||||||
|
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
|
||||||
export function getElementBaseValueForProperty({
|
: null,
|
||||||
element,
|
setValue: ({ element, value }) =>
|
||||||
propertyPath,
|
element.type === "text"
|
||||||
}: {
|
? {
|
||||||
element: TimelineElement;
|
...element,
|
||||||
propertyPath: AnimationPropertyPath;
|
background: { ...element.background, offsetY: value as number },
|
||||||
}): AnimationValue | null {
|
}
|
||||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
: element,
|
||||||
if (!definition.supportsElement({ element })) {
|
}),
|
||||||
return null;
|
"background.cornerRadius": createNumberPropertyDefinition({
|
||||||
}
|
numericRange: {
|
||||||
return definition.getValue({ element });
|
min: CORNER_RADIUS_MIN,
|
||||||
}
|
max: CORNER_RADIUS_MAX,
|
||||||
|
step: 1,
|
||||||
export function withElementBaseValueForProperty({
|
},
|
||||||
element,
|
supportsElement: ({ element }) => element.type === "text",
|
||||||
propertyPath,
|
getValue: ({ element }) =>
|
||||||
value,
|
element.type === "text"
|
||||||
}: {
|
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
|
||||||
element: TimelineElement;
|
: null,
|
||||||
propertyPath: AnimationPropertyPath;
|
setValue: ({ element, value }) =>
|
||||||
value: AnimationValue;
|
element.type === "text"
|
||||||
}): TimelineElement {
|
? {
|
||||||
const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
|
...element,
|
||||||
if (coercedValue === null) {
|
background: { ...element.background, cornerRadius: value as number },
|
||||||
return element;
|
}
|
||||||
}
|
: element,
|
||||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
}),
|
||||||
if (!definition.supportsElement({ element })) {
|
};
|
||||||
return element;
|
|
||||||
}
|
export function isAnimationPropertyPath(
|
||||||
return definition.setValue({ element, value: coercedValue });
|
propertyPath: string,
|
||||||
}
|
): propertyPath is AnimationPropertyPath {
|
||||||
|
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
|
||||||
export function getDefaultInterpolationForProperty({
|
}
|
||||||
propertyPath,
|
|
||||||
}: {
|
export function getAnimationPropertyDefinition({
|
||||||
propertyPath: AnimationPropertyPath;
|
propertyPath,
|
||||||
}): AnimationInterpolation {
|
}: {
|
||||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
propertyPath: AnimationPropertyPath;
|
||||||
return propertyDefinition.defaultInterpolation;
|
}): AnimationPropertyDefinition {
|
||||||
}
|
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
|
||||||
|
}
|
||||||
function applyNumericSpec({
|
|
||||||
value,
|
export function supportsAnimationProperty({
|
||||||
numericRange,
|
element,
|
||||||
}: {
|
propertyPath,
|
||||||
value: number;
|
}: {
|
||||||
numericRange: NumericSpec | undefined;
|
element: TimelineElement;
|
||||||
}): number {
|
propertyPath: AnimationPropertyPath;
|
||||||
if (!numericRange) {
|
}): boolean {
|
||||||
return value;
|
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||||
}
|
return propertyDefinition.supportsElement({ element });
|
||||||
|
}
|
||||||
const steppedValue =
|
|
||||||
numericRange.step != null
|
export function getElementBaseValueForProperty({
|
||||||
? snapToStep({ value, step: numericRange.step })
|
element,
|
||||||
: value;
|
propertyPath,
|
||||||
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
|
}: {
|
||||||
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
|
element: TimelineElement;
|
||||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
propertyPath: AnimationPropertyPath;
|
||||||
}
|
}): AnimationValue | null {
|
||||||
|
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||||
export function coerceAnimationValueForProperty({
|
if (!definition.supportsElement({ element })) {
|
||||||
propertyPath,
|
return null;
|
||||||
value,
|
}
|
||||||
}: {
|
return definition.getValue({ element });
|
||||||
propertyPath: AnimationPropertyPath;
|
}
|
||||||
value: AnimationValue;
|
|
||||||
}): AnimationValue | null {
|
export function withElementBaseValueForProperty({
|
||||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
element,
|
||||||
|
propertyPath,
|
||||||
if (propertyDefinition.valueKind === "number") {
|
value,
|
||||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
}: {
|
||||||
return null;
|
element: TimelineElement;
|
||||||
}
|
propertyPath: AnimationPropertyPath;
|
||||||
|
value: AnimationValue;
|
||||||
return applyNumericSpec({
|
}): TimelineElement {
|
||||||
value,
|
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||||
numericRange: propertyDefinition.numericRange,
|
const coercedValue = definition.coerceValue({ value });
|
||||||
});
|
if (coercedValue === null || !definition.supportsElement({ element })) {
|
||||||
}
|
return element;
|
||||||
|
}
|
||||||
if (propertyDefinition.valueKind === "color") {
|
return definition.setValue({ element, value: coercedValue });
|
||||||
return typeof value === "string" ? value : null;
|
}
|
||||||
}
|
|
||||||
|
export function getDefaultInterpolationForProperty({
|
||||||
if (propertyDefinition.valueKind === "vector") {
|
propertyPath,
|
||||||
return isVectorValue(value) ? value : null;
|
}: {
|
||||||
}
|
propertyPath: AnimationPropertyPath;
|
||||||
|
}): AnimationInterpolation {
|
||||||
if (typeof value === "string" || typeof value === "boolean") {
|
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||||
return value as DiscreteValue;
|
return propertyDefinition.defaultInterpolation;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
export function coerceAnimationValueForProperty({
|
||||||
}
|
propertyPath,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
propertyPath: AnimationPropertyPath;
|
||||||
|
value: AnimationValue;
|
||||||
|
}): AnimationValue | null {
|
||||||
|
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||||
|
return propertyDefinition.coerceValue({ value });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,135 +1,174 @@
|
||||||
import type {
|
import type {
|
||||||
AnimationPropertyPath,
|
AnimationPropertyPath,
|
||||||
ElementAnimations,
|
ElementAnimations,
|
||||||
} 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 "./interpolation";
|
} from "./binding-values";
|
||||||
import { getColorChannelForPath } from "./color-channel";
|
import {
|
||||||
import { getNumberChannelForPath } from "./number-channel";
|
getChannelValueAtTime,
|
||||||
import { getVectorChannelForPath } from "./vector-channel";
|
} from "./interpolation";
|
||||||
|
|
||||||
export function getElementLocalTime({
|
export function getElementLocalTime({
|
||||||
timelineTime,
|
timelineTime,
|
||||||
elementStartTime,
|
elementStartTime,
|
||||||
elementDuration,
|
elementDuration,
|
||||||
}: {
|
}: {
|
||||||
timelineTime: number;
|
timelineTime: number;
|
||||||
elementStartTime: number;
|
elementStartTime: number;
|
||||||
elementDuration: number;
|
elementDuration: number;
|
||||||
}): number {
|
}): number {
|
||||||
const localTime = timelineTime - elementStartTime;
|
const localTime = timelineTime - elementStartTime;
|
||||||
if (localTime <= 0) {
|
if (localTime <= 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (localTime >= elementDuration) {
|
if (localTime >= elementDuration) {
|
||||||
return elementDuration;
|
return elementDuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
return localTime;
|
return localTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveTransformAtTime({
|
export function resolveTransformAtTime({
|
||||||
baseTransform,
|
baseTransform,
|
||||||
animations,
|
animations,
|
||||||
localTime,
|
localTime,
|
||||||
}: {
|
}: {
|
||||||
baseTransform: Transform;
|
baseTransform: Transform;
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
localTime: number;
|
localTime: number;
|
||||||
}): 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,
|
||||||
}),
|
fallbackValue: baseTransform.position,
|
||||||
time: safeLocalTime,
|
}),
|
||||||
fallbackValue: baseTransform.position,
|
scaleX: resolveAnimationPathValueAtTime({
|
||||||
}),
|
animations,
|
||||||
scaleX: getNumberChannelValueAtTime({
|
propertyPath: "transform.scaleX",
|
||||||
channel: getNumberChannelForPath({
|
localTime: safeLocalTime,
|
||||||
animations,
|
fallbackValue: baseTransform.scaleX,
|
||||||
propertyPath: "transform.scaleX",
|
}),
|
||||||
}),
|
scaleY: resolveAnimationPathValueAtTime({
|
||||||
time: safeLocalTime,
|
animations,
|
||||||
fallbackValue: baseTransform.scaleX,
|
propertyPath: "transform.scaleY",
|
||||||
}),
|
localTime: safeLocalTime,
|
||||||
scaleY: getNumberChannelValueAtTime({
|
fallbackValue: baseTransform.scaleY,
|
||||||
channel: getNumberChannelForPath({
|
}),
|
||||||
animations,
|
rotate: resolveAnimationPathValueAtTime({
|
||||||
propertyPath: "transform.scaleY",
|
animations,
|
||||||
}),
|
propertyPath: "transform.rotate",
|
||||||
time: safeLocalTime,
|
localTime: safeLocalTime,
|
||||||
fallbackValue: baseTransform.scaleY,
|
fallbackValue: baseTransform.rotate,
|
||||||
}),
|
}),
|
||||||
rotate: getNumberChannelValueAtTime({
|
};
|
||||||
channel: getNumberChannelForPath({
|
}
|
||||||
animations,
|
|
||||||
propertyPath: "transform.rotate",
|
export function resolveOpacityAtTime({
|
||||||
}),
|
baseOpacity,
|
||||||
time: safeLocalTime,
|
animations,
|
||||||
fallbackValue: baseTransform.rotate,
|
localTime,
|
||||||
}),
|
}: {
|
||||||
};
|
baseOpacity: number;
|
||||||
}
|
animations: ElementAnimations | undefined;
|
||||||
|
localTime: number;
|
||||||
export function resolveOpacityAtTime({
|
}): number {
|
||||||
baseOpacity,
|
return resolveAnimationPathValueAtTime({
|
||||||
animations,
|
animations,
|
||||||
localTime,
|
propertyPath: "opacity",
|
||||||
}: {
|
localTime: Math.max(0, localTime),
|
||||||
baseOpacity: number;
|
fallbackValue: baseOpacity,
|
||||||
animations: ElementAnimations | undefined;
|
});
|
||||||
localTime: number;
|
}
|
||||||
}): number {
|
|
||||||
return getNumberChannelValueAtTime({
|
export function resolveNumberAtTime({
|
||||||
channel: getNumberChannelForPath({
|
baseValue,
|
||||||
animations,
|
animations,
|
||||||
propertyPath: "opacity",
|
propertyPath,
|
||||||
}),
|
localTime,
|
||||||
time: Math.max(0, localTime),
|
}: {
|
||||||
fallbackValue: baseOpacity,
|
baseValue: number;
|
||||||
});
|
animations: ElementAnimations | undefined;
|
||||||
}
|
propertyPath: AnimationPropertyPath;
|
||||||
|
localTime: number;
|
||||||
export function resolveNumberAtTime({
|
}): number {
|
||||||
baseValue,
|
return resolveAnimationPathValueAtTime({
|
||||||
animations,
|
animations,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
localTime,
|
localTime: Math.max(0, localTime),
|
||||||
}: {
|
fallbackValue: baseValue,
|
||||||
baseValue: number;
|
});
|
||||||
animations: ElementAnimations | undefined;
|
}
|
||||||
propertyPath: AnimationPropertyPath;
|
|
||||||
localTime: number;
|
export function resolveColorAtTime({
|
||||||
}): number {
|
baseColor,
|
||||||
return getNumberChannelValueAtTime({
|
animations,
|
||||||
channel: getNumberChannelForPath({ animations, propertyPath }),
|
propertyPath,
|
||||||
time: Math.max(0, localTime),
|
localTime,
|
||||||
fallbackValue: baseValue,
|
}: {
|
||||||
});
|
baseColor: string;
|
||||||
}
|
animations: ElementAnimations | undefined;
|
||||||
|
propertyPath: AnimationPropertyPath;
|
||||||
export function resolveColorAtTime({
|
localTime: number;
|
||||||
baseColor,
|
}): string {
|
||||||
animations,
|
return resolveAnimationPathValueAtTime({
|
||||||
propertyPath,
|
animations,
|
||||||
localTime,
|
propertyPath,
|
||||||
}: {
|
localTime: Math.max(0, localTime),
|
||||||
baseColor: string;
|
fallbackValue: baseColor,
|
||||||
animations: ElementAnimations | undefined;
|
});
|
||||||
propertyPath: AnimationPropertyPath;
|
}
|
||||||
localTime: number;
|
|
||||||
}): string {
|
export function resolveAnimationPathValueAtTime<
|
||||||
return getColorValueAtTime({
|
T extends number | string | boolean | Transform["position"],
|
||||||
channel: getColorChannelForPath({ animations, propertyPath }),
|
>({
|
||||||
time: Math.max(0, localTime),
|
animations,
|
||||||
fallbackValue: baseColor,
|
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,277 +1,285 @@
|
||||||
import type {
|
import type {
|
||||||
AnimationInterpolation,
|
AnimationBindingKind,
|
||||||
AnimationPath,
|
AnimationInterpolation,
|
||||||
AnimationValue,
|
AnimationPath,
|
||||||
AnimationValueKind,
|
AnimationValue,
|
||||||
} from "@/lib/animation/types";
|
} from "@/lib/animation/types";
|
||||||
import {
|
import {
|
||||||
parseEffectParamPath,
|
parseEffectParamPath,
|
||||||
isEffectParamPath,
|
isEffectParamPath,
|
||||||
} from "@/lib/animation/effect-param-channel";
|
} from "@/lib/animation/effect-param-channel";
|
||||||
import {
|
import {
|
||||||
isGraphicParamPath,
|
isGraphicParamPath,
|
||||||
parseGraphicParamPath,
|
parseGraphicParamPath,
|
||||||
} from "@/lib/animation/graphic-param-channel";
|
} from "@/lib/animation/graphic-param-channel";
|
||||||
import type { ParamDefinition } from "@/lib/params";
|
import type { ParamDefinition } from "@/lib/params";
|
||||||
import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
|
import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
|
||||||
import { getGraphicDefinition } from "@/lib/graphics";
|
import { getGraphicDefinition } from "@/lib/graphics";
|
||||||
import type { TimelineElement } from "@/lib/timeline";
|
import type { TimelineElement } from "@/lib/timeline";
|
||||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||||
import { snapToStep } from "@/utils/math";
|
import { snapToStep } from "@/utils/math";
|
||||||
import {
|
import {
|
||||||
coerceAnimationValueForProperty,
|
coerceAnimationValueForProperty,
|
||||||
getAnimationPropertyDefinition,
|
getAnimationPropertyDefinition,
|
||||||
getElementBaseValueForProperty,
|
getElementBaseValueForProperty,
|
||||||
isAnimationPropertyPath,
|
isAnimationPropertyPath,
|
||||||
type NumericSpec,
|
type NumericSpec,
|
||||||
withElementBaseValueForProperty,
|
withElementBaseValueForProperty,
|
||||||
} from "./property-registry";
|
} from "./property-registry";
|
||||||
|
import { parseColorToLinearRgba } from "./binding-values";
|
||||||
export interface AnimationPathDescriptor {
|
|
||||||
valueKind: AnimationValueKind;
|
export interface AnimationPathDescriptor {
|
||||||
defaultInterpolation: AnimationInterpolation;
|
kind: AnimationBindingKind;
|
||||||
numericRange?: NumericSpec;
|
defaultInterpolation: AnimationInterpolation;
|
||||||
getBaseValue(): AnimationValue | null;
|
numericRanges?: Partial<Record<string, NumericSpec>>;
|
||||||
setBaseValue(value: AnimationValue): TimelineElement;
|
coerceValue(value: AnimationValue): AnimationValue | null;
|
||||||
}
|
getBaseValue(): AnimationValue | null;
|
||||||
|
setBaseValue(value: AnimationValue): TimelineElement;
|
||||||
export function getParamValueKind({
|
}
|
||||||
param,
|
|
||||||
}: {
|
export function getParamValueKind({
|
||||||
param: ParamDefinition;
|
param,
|
||||||
}): AnimationValueKind {
|
}: {
|
||||||
if (param.type === "number") {
|
param: ParamDefinition;
|
||||||
return "number";
|
}): AnimationBindingKind {
|
||||||
}
|
if (param.type === "number") {
|
||||||
|
return "number";
|
||||||
if (param.type === "color") {
|
}
|
||||||
return "color";
|
|
||||||
}
|
if (param.type === "color") {
|
||||||
|
return "color";
|
||||||
return "discrete";
|
}
|
||||||
}
|
|
||||||
|
return "discrete";
|
||||||
export function getParamDefaultInterpolation({
|
}
|
||||||
param,
|
|
||||||
}: {
|
export function getParamDefaultInterpolation({
|
||||||
param: ParamDefinition;
|
param,
|
||||||
}): AnimationInterpolation {
|
}: {
|
||||||
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
param: ParamDefinition;
|
||||||
}
|
}): AnimationInterpolation {
|
||||||
|
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
||||||
function getParamNumericRange({
|
}
|
||||||
param,
|
|
||||||
}: {
|
function getParamNumericRange({
|
||||||
param: ParamDefinition;
|
param,
|
||||||
}): NumericSpec | undefined {
|
}: {
|
||||||
if (param.type !== "number") {
|
param: ParamDefinition;
|
||||||
return undefined;
|
}): Partial<Record<string, NumericSpec>> | undefined {
|
||||||
}
|
if (param.type !== "number") {
|
||||||
|
return undefined;
|
||||||
return {
|
}
|
||||||
min: param.min,
|
|
||||||
max: param.max,
|
return {
|
||||||
step: param.step,
|
value: {
|
||||||
};
|
min: param.min,
|
||||||
}
|
max: param.max,
|
||||||
|
step: param.step,
|
||||||
function coerceParamValue({
|
},
|
||||||
param,
|
};
|
||||||
value,
|
}
|
||||||
}: {
|
|
||||||
param: ParamDefinition;
|
export function coerceAnimationValueForParam({
|
||||||
value: AnimationValue;
|
param,
|
||||||
}): number | string | boolean | null {
|
value,
|
||||||
if (param.type === "number") {
|
}: {
|
||||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
param: ParamDefinition;
|
||||||
return null;
|
value: AnimationValue;
|
||||||
}
|
}): number | string | boolean | null {
|
||||||
|
if (param.type === "number") {
|
||||||
const steppedValue = snapToStep({ value, step: param.step });
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||||
const minValue = param.min;
|
return null;
|
||||||
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
|
}
|
||||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
|
||||||
}
|
const steppedValue = snapToStep({ value, step: param.step });
|
||||||
|
const minValue = param.min;
|
||||||
if (param.type === "color") {
|
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
|
||||||
return typeof value === "string" ? value : null;
|
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (param.type === "boolean") {
|
if (param.type === "color") {
|
||||||
return typeof value === "boolean" ? value : null;
|
return typeof value === "string" ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value !== "string") {
|
if (param.type === "boolean") {
|
||||||
return null;
|
return typeof value === "boolean" ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return param.options.some((option) => option.value === value) ? value : null;
|
if (typeof value !== "string") {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
function buildGraphicParamDescriptor({
|
|
||||||
element,
|
return param.options.some((option) => option.value === value) ? value : null;
|
||||||
paramKey,
|
}
|
||||||
}: {
|
|
||||||
element: TimelineElement;
|
function buildGraphicParamDescriptor({
|
||||||
paramKey: string;
|
element,
|
||||||
}): AnimationPathDescriptor | null {
|
paramKey,
|
||||||
if (element.type !== "graphic") {
|
}: {
|
||||||
return null;
|
element: TimelineElement;
|
||||||
}
|
paramKey: string;
|
||||||
|
}): AnimationPathDescriptor | null {
|
||||||
const definition = getGraphicDefinition({
|
if (element.type !== "graphic") {
|
||||||
definitionId: element.definitionId,
|
return null;
|
||||||
});
|
}
|
||||||
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
|
||||||
if (!param) {
|
const definition = getGraphicDefinition({
|
||||||
return null;
|
definitionId: element.definitionId,
|
||||||
}
|
});
|
||||||
|
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
||||||
return {
|
if (!param) {
|
||||||
valueKind: getParamValueKind({ param }),
|
return null;
|
||||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
}
|
||||||
numericRange: getParamNumericRange({ param }),
|
|
||||||
getBaseValue: () => element.params[param.key] ?? param.default,
|
return {
|
||||||
setBaseValue: (value) => {
|
kind: getParamValueKind({ param }),
|
||||||
const coercedValue = coerceParamValue({ param, value });
|
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||||
if (coercedValue === null) {
|
numericRanges: getParamNumericRange({ param }),
|
||||||
return element;
|
coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
|
||||||
}
|
getBaseValue: () => element.params[param.key] ?? param.default,
|
||||||
|
setBaseValue: (value) => {
|
||||||
return {
|
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||||
...element,
|
if (coercedValue === null) {
|
||||||
params: {
|
return element;
|
||||||
...element.params,
|
}
|
||||||
[param.key]: coercedValue,
|
|
||||||
},
|
return {
|
||||||
};
|
...element,
|
||||||
},
|
params: {
|
||||||
};
|
...element.params,
|
||||||
}
|
[param.key]: coercedValue,
|
||||||
|
},
|
||||||
function buildEffectParamDescriptor({
|
};
|
||||||
element,
|
},
|
||||||
effectId,
|
};
|
||||||
paramKey,
|
}
|
||||||
}: {
|
|
||||||
element: TimelineElement;
|
function buildEffectParamDescriptor({
|
||||||
effectId: string;
|
element,
|
||||||
paramKey: string;
|
effectId,
|
||||||
}): AnimationPathDescriptor | null {
|
paramKey,
|
||||||
if (!isVisualElement(element)) {
|
}: {
|
||||||
return null;
|
element: TimelineElement;
|
||||||
}
|
effectId: string;
|
||||||
|
paramKey: string;
|
||||||
const effect = element.effects?.find((candidate) => candidate.id === effectId);
|
}): AnimationPathDescriptor | null {
|
||||||
if (!effect) {
|
if (!isVisualElement(element)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerDefaultEffects();
|
const effect = element.effects?.find((candidate) => candidate.id === effectId);
|
||||||
const definition = effectsRegistry.get(effect.type);
|
if (!effect) {
|
||||||
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
return null;
|
||||||
if (!param) {
|
}
|
||||||
return null;
|
|
||||||
}
|
registerDefaultEffects();
|
||||||
|
const definition = effectsRegistry.get(effect.type);
|
||||||
return {
|
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
||||||
valueKind: getParamValueKind({ param }),
|
if (!param) {
|
||||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
return null;
|
||||||
numericRange: getParamNumericRange({ param }),
|
}
|
||||||
getBaseValue: () => effect.params[param.key] ?? param.default,
|
|
||||||
setBaseValue: (value) => {
|
return {
|
||||||
const coercedValue = coerceParamValue({ param, value });
|
kind: getParamValueKind({ param }),
|
||||||
if (coercedValue === null) {
|
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||||
return element;
|
numericRanges: getParamNumericRange({ param }),
|
||||||
}
|
coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
|
||||||
|
getBaseValue: () => effect.params[param.key] ?? param.default,
|
||||||
return {
|
setBaseValue: (value) => {
|
||||||
...element,
|
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||||
effects:
|
if (coercedValue === null) {
|
||||||
element.effects?.map((candidate) =>
|
return element;
|
||||||
candidate.id !== effectId
|
}
|
||||||
? candidate
|
|
||||||
: {
|
return {
|
||||||
...candidate,
|
...element,
|
||||||
params: {
|
effects:
|
||||||
...candidate.params,
|
element.effects?.map((candidate) =>
|
||||||
[param.key]: coercedValue,
|
candidate.id !== effectId
|
||||||
},
|
? candidate
|
||||||
},
|
: {
|
||||||
) ?? element.effects,
|
...candidate,
|
||||||
};
|
params: {
|
||||||
},
|
...candidate.params,
|
||||||
};
|
[param.key]: coercedValue,
|
||||||
}
|
},
|
||||||
|
},
|
||||||
export function isAnimationPath(
|
) ?? element.effects,
|
||||||
propertyPath: string,
|
};
|
||||||
): propertyPath is AnimationPath {
|
},
|
||||||
return (
|
};
|
||||||
isAnimationPropertyPath(propertyPath) ||
|
}
|
||||||
isGraphicParamPath(propertyPath) ||
|
|
||||||
isEffectParamPath(propertyPath)
|
export function isAnimationPath(
|
||||||
);
|
propertyPath: string,
|
||||||
}
|
): propertyPath is AnimationPath {
|
||||||
|
return (
|
||||||
export function resolveAnimationTarget({
|
isAnimationPropertyPath(propertyPath) ||
|
||||||
element,
|
isGraphicParamPath(propertyPath) ||
|
||||||
path,
|
isEffectParamPath(propertyPath)
|
||||||
}: {
|
);
|
||||||
element: TimelineElement;
|
}
|
||||||
path: AnimationPath;
|
|
||||||
}): AnimationPathDescriptor | null {
|
export function resolveAnimationTarget({
|
||||||
if (isAnimationPropertyPath(path)) {
|
element,
|
||||||
const propertyDefinition = getAnimationPropertyDefinition({
|
path,
|
||||||
propertyPath: path,
|
}: {
|
||||||
});
|
element: TimelineElement;
|
||||||
if (!propertyDefinition.supportsElement({ element })) {
|
path: AnimationPath;
|
||||||
return null;
|
}): AnimationPathDescriptor | null {
|
||||||
}
|
if (isAnimationPropertyPath(path)) {
|
||||||
|
const propertyDefinition = getAnimationPropertyDefinition({
|
||||||
return {
|
propertyPath: path,
|
||||||
valueKind: propertyDefinition.valueKind,
|
});
|
||||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
if (!propertyDefinition.supportsElement({ element })) {
|
||||||
numericRange: propertyDefinition.numericRange,
|
return null;
|
||||||
getBaseValue: () =>
|
}
|
||||||
getElementBaseValueForProperty({
|
|
||||||
element,
|
return {
|
||||||
propertyPath: path,
|
kind: propertyDefinition.kind,
|
||||||
}),
|
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||||
setBaseValue: (value) => {
|
numericRanges: propertyDefinition.numericRanges,
|
||||||
const coercedValue = coerceAnimationValueForProperty({
|
coerceValue: (value) =>
|
||||||
propertyPath: path,
|
coerceAnimationValueForProperty({
|
||||||
value,
|
propertyPath: path,
|
||||||
});
|
value,
|
||||||
if (coercedValue === null) {
|
}),
|
||||||
return element;
|
getBaseValue: () =>
|
||||||
}
|
getElementBaseValueForProperty({
|
||||||
|
element,
|
||||||
return withElementBaseValueForProperty({
|
propertyPath: path,
|
||||||
element,
|
}),
|
||||||
propertyPath: path,
|
setBaseValue: (value) => {
|
||||||
value: coercedValue,
|
const coercedValue = propertyDefinition.coerceValue({ value });
|
||||||
});
|
if (coercedValue === null) {
|
||||||
},
|
return element;
|
||||||
};
|
}
|
||||||
}
|
|
||||||
|
return withElementBaseValueForProperty({
|
||||||
const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
|
element,
|
||||||
if (graphicParamTarget) {
|
propertyPath: path,
|
||||||
return buildGraphicParamDescriptor({
|
value: coercedValue,
|
||||||
element,
|
});
|
||||||
paramKey: graphicParamTarget.paramKey,
|
},
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectParamTarget = parseEffectParamPath({ propertyPath: path });
|
const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
|
||||||
if (effectParamTarget) {
|
if (graphicParamTarget) {
|
||||||
return buildEffectParamDescriptor({
|
return buildGraphicParamDescriptor({
|
||||||
element,
|
element,
|
||||||
effectId: effectParamTarget.effectId,
|
paramKey: graphicParamTarget.paramKey,
|
||||||
paramKey: effectParamTarget.paramKey,
|
});
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
const effectParamTarget = parseEffectParamPath({ propertyPath: path });
|
||||||
return null;
|
if (effectParamTarget) {
|
||||||
}
|
return buildEffectParamDescriptor({
|
||||||
|
element,
|
||||||
|
effectId: effectParamTarget.effectId,
|
||||||
|
paramKey: effectParamTarget.paramKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,119 +1,160 @@
|
||||||
export const ANIMATION_PROPERTY_PATHS = [
|
export const ANIMATION_PROPERTY_PATHS = [
|
||||||
"transform.position",
|
"transform.position",
|
||||||
"transform.scaleX",
|
"transform.scaleX",
|
||||||
"transform.scaleY",
|
"transform.scaleY",
|
||||||
"transform.rotate",
|
"transform.rotate",
|
||||||
"opacity",
|
"opacity",
|
||||||
"volume",
|
"volume",
|
||||||
"color",
|
"color",
|
||||||
"background.color",
|
"background.color",
|
||||||
"background.paddingX",
|
"background.paddingX",
|
||||||
"background.paddingY",
|
"background.paddingY",
|
||||||
"background.offsetX",
|
"background.offsetX",
|
||||||
"background.offsetY",
|
"background.offsetY",
|
||||||
"background.cornerRadius",
|
"background.cornerRadius",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
|
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
|
||||||
export type GraphicParamPath = `params.${string}`;
|
export type GraphicParamPath = `params.${string}`;
|
||||||
export type EffectParamPath = `effects.${string}.params.${string}`;
|
export type EffectParamPath = `effects.${string}.params.${string}`;
|
||||||
export type AnimationPath =
|
export type AnimationPath =
|
||||||
| AnimationPropertyPath
|
| AnimationPropertyPath
|
||||||
| GraphicParamPath
|
| GraphicParamPath
|
||||||
| EffectParamPath;
|
| EffectParamPath;
|
||||||
|
|
||||||
export const ANIMATION_PROPERTY_GROUPS = {
|
export const ANIMATION_PROPERTY_GROUPS = {
|
||||||
"transform.scale": ["transform.scaleX", "transform.scaleY"],
|
"transform.scale": ["transform.scaleX", "transform.scaleY"],
|
||||||
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
|
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
|
||||||
|
|
||||||
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 DiscreteValue = boolean | string;
|
||||||
export type AnimationValueKind = "number" | "color" | "discrete" | "vector";
|
export type AnimationValue = number | string | boolean | VectorValue;
|
||||||
export type DiscreteValue = boolean | string;
|
|
||||||
export type AnimationValue = number | string | boolean | VectorValue;
|
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
|
||||||
|
export type DiscreteKeyframeInterpolation = "hold";
|
||||||
export type ContinuousKeyframeInterpolation = "linear" | "hold";
|
export type AnimationInterpolation =
|
||||||
export type DiscreteKeyframeInterpolation = "hold";
|
| ContinuousKeyframeInterpolation
|
||||||
export type AnimationInterpolation =
|
| DiscreteKeyframeInterpolation;
|
||||||
| ContinuousKeyframeInterpolation
|
|
||||||
| DiscreteKeyframeInterpolation;
|
export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
|
||||||
|
export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
|
||||||
interface BaseAnimationKeyframe<
|
export type ScalarSegmentType = "step" | "linear" | "bezier";
|
||||||
TValue extends AnimationValue,
|
export type TangentMode = "auto" | "aligned" | "broken" | "flat";
|
||||||
TInterpolation extends AnimationInterpolation,
|
export type ChannelExtrapolationMode = "hold" | "linear";
|
||||||
> {
|
|
||||||
id: string;
|
export interface CurveHandle {
|
||||||
time: number; // relative to element start time
|
dt: number;
|
||||||
value: TValue;
|
dv: number;
|
||||||
interpolation: TInterpolation;
|
}
|
||||||
}
|
|
||||||
|
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
|
||||||
export interface NumberKeyframe
|
id: string;
|
||||||
extends BaseAnimationKeyframe<number, ContinuousKeyframeInterpolation> {}
|
time: number; // relative to element start time
|
||||||
|
value: TValue;
|
||||||
export interface ColorKeyframe
|
}
|
||||||
extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {}
|
|
||||||
|
export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
|
||||||
export interface DiscreteKeyframe
|
leftHandle?: CurveHandle;
|
||||||
extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {}
|
rightHandle?: CurveHandle;
|
||||||
|
segmentToNext: ScalarSegmentType;
|
||||||
export interface VectorKeyframe
|
tangentMode: TangentMode;
|
||||||
extends BaseAnimationKeyframe<VectorValue, ContinuousKeyframeInterpolation> {}
|
}
|
||||||
|
|
||||||
export type AnimationKeyframe =
|
export interface DiscreteAnimationKey
|
||||||
| NumberKeyframe
|
extends BaseAnimationKeyframe<DiscreteValue> {}
|
||||||
| ColorKeyframe
|
|
||||||
| DiscreteKeyframe
|
export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
|
||||||
| VectorKeyframe;
|
|
||||||
|
export interface ScalarAnimationChannel {
|
||||||
interface BaseAnimationChannel<
|
kind: "scalar";
|
||||||
TValueKind extends AnimationValueKind,
|
keys: ScalarAnimationKey[];
|
||||||
TKeyframe extends AnimationKeyframe,
|
extrapolation?: {
|
||||||
> {
|
before: ChannelExtrapolationMode;
|
||||||
valueKind: TValueKind;
|
after: ChannelExtrapolationMode;
|
||||||
keyframes: TKeyframe[];
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NumberAnimationChannel
|
export interface DiscreteAnimationChannel {
|
||||||
extends BaseAnimationChannel<"number", NumberKeyframe> {}
|
kind: "discrete";
|
||||||
|
keys: DiscreteAnimationKey[];
|
||||||
export interface ColorAnimationChannel
|
}
|
||||||
extends BaseAnimationChannel<"color", ColorKeyframe> {}
|
|
||||||
|
export type AnimationChannel =
|
||||||
export interface DiscreteAnimationChannel
|
| ScalarAnimationChannel
|
||||||
extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {}
|
| DiscreteAnimationChannel;
|
||||||
|
|
||||||
export interface VectorAnimationChannel
|
export type ElementAnimationChannelMap = Record<
|
||||||
extends BaseAnimationChannel<"vector", VectorKeyframe> {}
|
string,
|
||||||
|
AnimationChannel | undefined
|
||||||
export type AnimationChannel =
|
>;
|
||||||
| NumberAnimationChannel
|
|
||||||
| ColorAnimationChannel
|
export interface AnimationBindingComponent<TKey extends string = string> {
|
||||||
| DiscreteAnimationChannel
|
key: TKey;
|
||||||
| VectorAnimationChannel;
|
channelId: string;
|
||||||
|
}
|
||||||
export type ElementAnimationChannelMap = Record<
|
|
||||||
string,
|
interface BaseAnimationBinding<
|
||||||
AnimationChannel | undefined
|
TKind extends AnimationBindingKind,
|
||||||
>;
|
TComponentKey extends string,
|
||||||
|
> {
|
||||||
export interface ElementAnimations {
|
path: AnimationPath;
|
||||||
channels: ElementAnimationChannelMap;
|
kind: TKind;
|
||||||
}
|
components: AnimationBindingComponent<TComponentKey>[];
|
||||||
|
}
|
||||||
export interface ElementKeyframe {
|
|
||||||
propertyPath: AnimationPath;
|
export interface NumberAnimationBinding
|
||||||
id: string;
|
extends BaseAnimationBinding<"number", "value"> {}
|
||||||
time: number;
|
|
||||||
value: AnimationValue;
|
export interface Vector2AnimationBinding
|
||||||
interpolation: AnimationInterpolation;
|
extends BaseAnimationBinding<"vector2", "x" | "y"> {}
|
||||||
}
|
|
||||||
|
export interface ColorAnimationBinding
|
||||||
export interface SelectedKeyframeRef {
|
extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> {
|
||||||
trackId: string;
|
colorSpace: "srgb-linear";
|
||||||
elementId: string;
|
}
|
||||||
propertyPath: AnimationPath;
|
|
||||||
keyframeId: string;
|
export interface DiscreteAnimationBinding
|
||||||
}
|
extends BaseAnimationBinding<"discrete", "value"> {}
|
||||||
|
|
||||||
|
export type AnimationBindingInstance =
|
||||||
|
| NumberAnimationBinding
|
||||||
|
| Vector2AnimationBinding
|
||||||
|
| ColorAnimationBinding
|
||||||
|
| DiscreteAnimationBinding;
|
||||||
|
|
||||||
|
export interface AnimationBindingByKind {
|
||||||
|
number: NumberAnimationBinding;
|
||||||
|
vector2: Vector2AnimationBinding;
|
||||||
|
color: ColorAnimationBinding;
|
||||||
|
discrete: DiscreteAnimationBinding;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnimationBindingOfKind<TKind extends AnimationBindingKind> =
|
||||||
|
AnimationBindingByKind[TKind];
|
||||||
|
|
||||||
|
export type ElementAnimationBindingMap = Record<
|
||||||
|
string,
|
||||||
|
AnimationBindingInstance | undefined
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface ElementAnimations {
|
||||||
|
bindings: ElementAnimationBindingMap;
|
||||||
|
channels: ElementAnimationChannelMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElementKeyframe {
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
id: string;
|
||||||
|
time: number;
|
||||||
|
value: AnimationValue;
|
||||||
|
interpolation: AnimationInterpolation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectedKeyframeRef {
|
||||||
|
trackId: string;
|
||||||
|
elementId: string;
|
||||||
|
propertyPath: AnimationPath;
|
||||||
|
keyframeId: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -1,235 +1,237 @@
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
|
import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
|
||||||
import { getMaskSnapGeometry } from "@/lib/masks/geometry";
|
import { getMaskSnapGeometry } from "@/lib/masks/geometry";
|
||||||
import { snapMaskInteraction } from "@/lib/masks/snap";
|
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||||
import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
|
import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
|
||||||
|
|
||||||
const bounds: ElementBounds = {
|
const bounds: ElementBounds = {
|
||||||
cx: 200,
|
cx: 200,
|
||||||
cy: 150,
|
cy: 150,
|
||||||
width: 200,
|
width: 200,
|
||||||
height: 100,
|
height: 100,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const canvasSize = {
|
const canvasSize = {
|
||||||
width: 400,
|
width: 400,
|
||||||
height: 300,
|
height: 300,
|
||||||
};
|
};
|
||||||
|
|
||||||
const snapThreshold = {
|
const snapThreshold = {
|
||||||
x: 8,
|
x: 8,
|
||||||
y: 8,
|
y: 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildSplitParams(
|
function buildSplitParams(
|
||||||
overrides: Partial<SplitMaskParams> = {},
|
overrides: Partial<SplitMaskParams> = {},
|
||||||
): SplitMaskParams {
|
): SplitMaskParams {
|
||||||
return {
|
return {
|
||||||
feather: 0,
|
feather: 0,
|
||||||
inverted: false,
|
inverted: false,
|
||||||
strokeColor: "#ffffff",
|
strokeColor: "#ffffff",
|
||||||
strokeWidth: 0,
|
strokeWidth: 0,
|
||||||
centerX: 0,
|
strokeAlign: "center",
|
||||||
centerY: 0,
|
centerX: 0,
|
||||||
rotation: 0,
|
centerY: 0,
|
||||||
...overrides,
|
rotation: 0,
|
||||||
};
|
...overrides,
|
||||||
}
|
};
|
||||||
|
}
|
||||||
function buildRectangleParams(
|
|
||||||
overrides: Partial<RectangleMaskParams> = {},
|
function buildRectangleParams(
|
||||||
): RectangleMaskParams {
|
overrides: Partial<RectangleMaskParams> = {},
|
||||||
return {
|
): RectangleMaskParams {
|
||||||
feather: 0,
|
return {
|
||||||
inverted: false,
|
feather: 0,
|
||||||
strokeColor: "#ffffff",
|
inverted: false,
|
||||||
strokeWidth: 0,
|
strokeColor: "#ffffff",
|
||||||
centerX: 0,
|
strokeWidth: 0,
|
||||||
centerY: 0,
|
strokeAlign: "center",
|
||||||
width: 0.4,
|
centerX: 0,
|
||||||
height: 0.2,
|
centerY: 0,
|
||||||
rotation: 0,
|
width: 0.4,
|
||||||
scale: 1,
|
height: 0.2,
|
||||||
...overrides,
|
rotation: 0,
|
||||||
};
|
scale: 1,
|
||||||
}
|
...overrides,
|
||||||
|
};
|
||||||
function sortSegment(
|
}
|
||||||
segment: [{ x: number; y: number }, { x: number; y: number }],
|
|
||||||
): [{ x: number; y: number }, { x: number; y: number }] {
|
function sortSegment(
|
||||||
return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [
|
segment: [{ x: number; y: number }, { x: number; y: number }],
|
||||||
{ x: number; y: number },
|
): [{ x: number; y: number }, { x: number; y: number }] {
|
||||||
{ x: number; y: number },
|
return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [
|
||||||
];
|
{ x: number; y: number },
|
||||||
}
|
{ x: number; y: number },
|
||||||
|
];
|
||||||
describe("mask geometry", () => {
|
}
|
||||||
test("resolves split mask center from centerX and centerY", () => {
|
|
||||||
expect(
|
describe("mask geometry", () => {
|
||||||
getMaskSnapGeometry({
|
test("resolves split mask center from centerX and centerY", () => {
|
||||||
params: buildSplitParams({
|
expect(
|
||||||
centerX: 0.25,
|
getMaskSnapGeometry({
|
||||||
centerY: -0.5,
|
params: buildSplitParams({
|
||||||
rotation: 45,
|
centerX: 0.25,
|
||||||
}),
|
centerY: -0.5,
|
||||||
bounds,
|
rotation: 45,
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
bounds,
|
||||||
position: { x: 50, y: -50 },
|
}),
|
||||||
size: { width: 0, height: 0 },
|
).toEqual({
|
||||||
rotation: 45,
|
position: { x: 50, y: -50 },
|
||||||
});
|
size: { width: 0, height: 0 },
|
||||||
});
|
rotation: 45,
|
||||||
|
});
|
||||||
test("resolves box mask center and size from centerX and centerY", () => {
|
});
|
||||||
expect(
|
|
||||||
getMaskSnapGeometry({
|
test("resolves box mask center and size from centerX and centerY", () => {
|
||||||
params: buildRectangleParams({
|
expect(
|
||||||
centerX: -0.25,
|
getMaskSnapGeometry({
|
||||||
centerY: 0.5,
|
params: buildRectangleParams({
|
||||||
width: 0.5,
|
centerX: -0.25,
|
||||||
height: 0.6,
|
centerY: 0.5,
|
||||||
rotation: 30,
|
width: 0.5,
|
||||||
}),
|
height: 0.6,
|
||||||
bounds,
|
rotation: 30,
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
bounds,
|
||||||
position: { x: -50, y: 50 },
|
}),
|
||||||
size: { width: 100, height: 60 },
|
).toEqual({
|
||||||
rotation: 30,
|
position: { x: -50, y: 50 },
|
||||||
});
|
size: { width: 100, height: 60 },
|
||||||
});
|
rotation: 30,
|
||||||
|
});
|
||||||
test("returns a vertical split stroke segment for rotation 0", () => {
|
});
|
||||||
const segment = getSplitMaskStrokeSegment({
|
|
||||||
resolvedParams: buildSplitParams(),
|
test("returns a vertical split stroke segment for rotation 0", () => {
|
||||||
width: bounds.width,
|
const segment = getSplitMaskStrokeSegment({
|
||||||
height: bounds.height,
|
resolvedParams: buildSplitParams(),
|
||||||
});
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
expect(segment).not.toBeNull();
|
});
|
||||||
if (!segment) {
|
|
||||||
throw new Error("Expected split stroke segment for rotation 0");
|
expect(segment).not.toBeNull();
|
||||||
}
|
if (!segment) {
|
||||||
expect(sortSegment(segment)).toEqual([
|
throw new Error("Expected split stroke segment for rotation 0");
|
||||||
{ x: bounds.width / 2, y: 0 },
|
}
|
||||||
{ x: bounds.width / 2, y: bounds.height },
|
expect(sortSegment(segment)).toEqual([
|
||||||
]);
|
{ x: bounds.width / 2, y: 0 },
|
||||||
});
|
{ x: bounds.width / 2, y: bounds.height },
|
||||||
|
]);
|
||||||
test("returns a horizontal split stroke segment for rotation 90", () => {
|
});
|
||||||
const segment = getSplitMaskStrokeSegment({
|
|
||||||
resolvedParams: buildSplitParams({ rotation: 90 }),
|
test("returns a horizontal split stroke segment for rotation 90", () => {
|
||||||
width: bounds.width,
|
const segment = getSplitMaskStrokeSegment({
|
||||||
height: bounds.height,
|
resolvedParams: buildSplitParams({ rotation: 90 }),
|
||||||
});
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
expect(segment).not.toBeNull();
|
});
|
||||||
if (!segment) {
|
|
||||||
throw new Error("Expected split stroke segment for rotation 90");
|
expect(segment).not.toBeNull();
|
||||||
}
|
if (!segment) {
|
||||||
expect(sortSegment(segment)).toEqual([
|
throw new Error("Expected split stroke segment for rotation 90");
|
||||||
{ x: 0, y: bounds.height / 2 },
|
}
|
||||||
{ x: bounds.width, y: bounds.height / 2 },
|
expect(sortSegment(segment)).toEqual([
|
||||||
]);
|
{ x: 0, y: bounds.height / 2 },
|
||||||
});
|
{ x: bounds.width, y: bounds.height / 2 },
|
||||||
});
|
]);
|
||||||
|
});
|
||||||
describe("mask snapping", () => {
|
});
|
||||||
test("snaps split mask movement using the shared position pipeline", () => {
|
|
||||||
const result = snapMaskInteraction({
|
describe("mask snapping", () => {
|
||||||
handleId: "position",
|
test("snaps split mask movement using the shared position pipeline", () => {
|
||||||
startParams: buildSplitParams({
|
const result = snapMaskInteraction({
|
||||||
centerX: 0.03,
|
handleId: "position",
|
||||||
centerY: -0.04,
|
startParams: buildSplitParams({
|
||||||
}),
|
centerX: 0.03,
|
||||||
proposedParams: buildSplitParams({
|
centerY: -0.04,
|
||||||
centerX: 0.03,
|
}),
|
||||||
centerY: -0.04,
|
proposedParams: buildSplitParams({
|
||||||
}),
|
centerX: 0.03,
|
||||||
bounds,
|
centerY: -0.04,
|
||||||
canvasSize,
|
}),
|
||||||
snapThreshold,
|
bounds,
|
||||||
});
|
canvasSize,
|
||||||
|
snapThreshold,
|
||||||
expect(result.params.centerX).toBe(0);
|
});
|
||||||
expect(result.params.centerY).toBe(0);
|
|
||||||
expect(result.activeLines).toEqual([
|
expect(result.params.centerX).toBe(0);
|
||||||
{ type: "vertical", position: 0 },
|
expect(result.params.centerY).toBe(0);
|
||||||
{ type: "horizontal", position: 0 },
|
expect(result.activeLines).toEqual([
|
||||||
]);
|
{ type: "vertical", position: 0 },
|
||||||
});
|
{ type: "horizontal", position: 0 },
|
||||||
|
]);
|
||||||
test("snaps box mask movement against element center and edges", () => {
|
});
|
||||||
const result = snapMaskInteraction({
|
|
||||||
handleId: "position",
|
test("snaps box mask movement against element center and edges", () => {
|
||||||
startParams: buildRectangleParams(),
|
const result = snapMaskInteraction({
|
||||||
proposedParams: buildRectangleParams({
|
handleId: "position",
|
||||||
centerX: 0.29,
|
startParams: buildRectangleParams(),
|
||||||
centerY: 0.03,
|
proposedParams: buildRectangleParams({
|
||||||
}),
|
centerX: 0.29,
|
||||||
bounds,
|
centerY: 0.03,
|
||||||
canvasSize,
|
}),
|
||||||
snapThreshold,
|
bounds,
|
||||||
});
|
canvasSize,
|
||||||
|
snapThreshold,
|
||||||
expect(result.params.centerX).toBeCloseTo(0.3);
|
});
|
||||||
expect(result.params.centerY).toBe(0);
|
|
||||||
expect(result.activeLines).toEqual([
|
expect(result.params.centerX).toBeCloseTo(0.3);
|
||||||
{ type: "vertical", position: 100 },
|
expect(result.params.centerY).toBe(0);
|
||||||
{ type: "horizontal", position: 0 },
|
expect(result.activeLines).toEqual([
|
||||||
]);
|
{ type: "vertical", position: 100 },
|
||||||
});
|
{ type: "horizontal", position: 0 },
|
||||||
|
]);
|
||||||
test("snaps mask rotation through the shared rotation path", () => {
|
});
|
||||||
const result = snapMaskInteraction({
|
|
||||||
handleId: "rotation",
|
test("snaps mask rotation through the shared rotation path", () => {
|
||||||
startParams: buildRectangleParams(),
|
const result = snapMaskInteraction({
|
||||||
proposedParams: buildRectangleParams({
|
handleId: "rotation",
|
||||||
rotation: 88,
|
startParams: buildRectangleParams(),
|
||||||
}),
|
proposedParams: buildRectangleParams({
|
||||||
bounds,
|
rotation: 88,
|
||||||
canvasSize,
|
}),
|
||||||
snapThreshold,
|
bounds,
|
||||||
});
|
canvasSize,
|
||||||
|
snapThreshold,
|
||||||
expect(result.params.rotation).toBe(90);
|
});
|
||||||
expect(result.activeLines).toEqual([]);
|
|
||||||
});
|
expect(result.params.rotation).toBe(90);
|
||||||
|
expect(result.activeLines).toEqual([]);
|
||||||
test("snaps edge resize for box masks", () => {
|
});
|
||||||
const result = snapMaskInteraction({
|
|
||||||
handleId: "right",
|
test("snaps edge resize for box masks", () => {
|
||||||
startParams: buildRectangleParams(),
|
const result = snapMaskInteraction({
|
||||||
proposedParams: buildRectangleParams({
|
handleId: "right",
|
||||||
width: 0.98,
|
startParams: buildRectangleParams(),
|
||||||
}),
|
proposedParams: buildRectangleParams({
|
||||||
bounds,
|
width: 0.98,
|
||||||
canvasSize,
|
}),
|
||||||
snapThreshold,
|
bounds,
|
||||||
});
|
canvasSize,
|
||||||
|
snapThreshold,
|
||||||
expect(result.params.width).toBe(1);
|
});
|
||||||
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
|
||||||
});
|
expect(result.params.width).toBe(1);
|
||||||
|
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
||||||
test("snaps corner resize for box masks", () => {
|
});
|
||||||
const result = snapMaskInteraction({
|
|
||||||
handleId: "bottom-right",
|
test("snaps corner resize for box masks", () => {
|
||||||
startParams: buildRectangleParams(),
|
const result = snapMaskInteraction({
|
||||||
proposedParams: buildRectangleParams({
|
handleId: "bottom-right",
|
||||||
width: 0.99,
|
startParams: buildRectangleParams(),
|
||||||
height: 0.495,
|
proposedParams: buildRectangleParams({
|
||||||
}),
|
width: 0.99,
|
||||||
bounds,
|
height: 0.495,
|
||||||
canvasSize,
|
}),
|
||||||
snapThreshold,
|
bounds,
|
||||||
});
|
canvasSize,
|
||||||
|
snapThreshold,
|
||||||
expect(result.params.width).toBe(1);
|
});
|
||||||
expect(result.params.height).toBe(0.5);
|
|
||||||
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
expect(result.params.width).toBe(1);
|
||||||
});
|
expect(result.params.height).toBe(0.5);
|
||||||
});
|
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,92 +1,94 @@
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { transformProjectV5ToV6 } from "../transformers/v5-to-v6";
|
import { transformProjectV5ToV6 } from "../transformers/v5-to-v6";
|
||||||
import { v5Project } from "./fixtures";
|
import { v5Project } from "./fixtures";
|
||||||
|
|
||||||
describe("V5 to V6 Migration", () => {
|
describe("V5 to V6 Migration", () => {
|
||||||
test("converts number bookmarks to Bookmark objects", async () => {
|
test("converts number bookmarks to Bookmark objects", async () => {
|
||||||
const result = transformProjectV5ToV6({
|
const result = transformProjectV5ToV6({
|
||||||
project: v5Project as Parameters<
|
project: v5Project as Parameters<
|
||||||
typeof transformProjectV5ToV6
|
typeof transformProjectV5ToV6
|
||||||
>[0]["project"],
|
>[0]["project"],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.skipped).toBe(false);
|
expect(result.skipped).toBe(false);
|
||||||
expect(result.project.version).toBe(6);
|
expect(result.project.version).toBe(6);
|
||||||
|
|
||||||
const mainScene = (
|
const mainScene = (
|
||||||
result.project.scenes as Array<{ bookmarks: unknown[] }>
|
result.project.scenes as Array<{ bookmarks: unknown[] }>
|
||||||
)[0];
|
)[0];
|
||||||
expect(mainScene.bookmarks).toEqual([
|
expect(mainScene.bookmarks).toEqual([
|
||||||
{ time: 2.0 },
|
{ time: 2.0 },
|
||||||
{ time: 5.5 },
|
{ time: 5.5 },
|
||||||
{ time: 12.0 },
|
{ time: 12.0 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const introScene = (
|
const introScene = (
|
||||||
result.project.scenes as Array<{ bookmarks: unknown[] }>
|
result.project.scenes as Array<{ bookmarks: unknown[] }>
|
||||||
)[1];
|
)[1];
|
||||||
expect(introScene.bookmarks).toEqual([]);
|
expect(introScene.bookmarks).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("skips projects that are already v6", () => {
|
test("skips projects that are already v6", () => {
|
||||||
const result = transformProjectV5ToV6({
|
const firstScene = (v5Project as { scenes: Array<Record<string, unknown>> })
|
||||||
project: {
|
.scenes[0];
|
||||||
...v5Project,
|
const result = transformProjectV5ToV6({
|
||||||
version: 6,
|
project: {
|
||||||
scenes: [
|
...v5Project,
|
||||||
{
|
version: 6,
|
||||||
...(v5Project as { scenes: unknown[] }).scenes[0],
|
scenes: [
|
||||||
bookmarks: [{ time: 2 }, { time: 5 }],
|
{
|
||||||
},
|
...firstScene,
|
||||||
],
|
bookmarks: [{ time: 2 }, { time: 5 }],
|
||||||
} as Parameters<typeof transformProjectV5ToV6>[0]["project"],
|
},
|
||||||
});
|
],
|
||||||
|
} as Parameters<typeof transformProjectV5ToV6>[0]["project"],
|
||||||
expect(result.skipped).toBe(true);
|
});
|
||||||
expect(result.reason).toBe("already v6");
|
|
||||||
});
|
expect(result.skipped).toBe(true);
|
||||||
|
expect(result.reason).toBe("already v6");
|
||||||
test("skips projects with no id", () => {
|
});
|
||||||
const result = transformProjectV5ToV6({
|
|
||||||
project: {
|
test("skips projects with no id", () => {
|
||||||
version: 5,
|
const result = transformProjectV5ToV6({
|
||||||
scenes: [],
|
project: {
|
||||||
} as Parameters<typeof transformProjectV5ToV6>[0]["project"],
|
version: 5,
|
||||||
});
|
scenes: [],
|
||||||
|
} as Parameters<typeof transformProjectV5ToV6>[0]["project"],
|
||||||
expect(result.skipped).toBe(true);
|
});
|
||||||
expect(result.reason).toBe("no project id");
|
|
||||||
});
|
expect(result.skipped).toBe(true);
|
||||||
|
expect(result.reason).toBe("no project id");
|
||||||
test("preserves existing Bookmark objects with note, color, duration", () => {
|
});
|
||||||
const projectWithRichBookmarks = {
|
|
||||||
...v5Project,
|
test("preserves existing Bookmark objects with note, color, duration", () => {
|
||||||
version: 5,
|
const projectWithRichBookmarks = {
|
||||||
scenes: [
|
...v5Project,
|
||||||
{
|
version: 5,
|
||||||
...(v5Project as { scenes: Array<Record<string, unknown>> })
|
scenes: [
|
||||||
.scenes[0],
|
{
|
||||||
bookmarks: [
|
...(v5Project as { scenes: Array<Record<string, unknown>> })
|
||||||
{ time: 1, note: "Intro", color: "#ef4444" },
|
.scenes[0],
|
||||||
{ time: 5.5, duration: 2 },
|
bookmarks: [
|
||||||
],
|
{ time: 1, note: "Intro", color: "#ef4444" },
|
||||||
},
|
{ time: 5.5, duration: 2 },
|
||||||
],
|
],
|
||||||
};
|
},
|
||||||
|
],
|
||||||
const result = transformProjectV5ToV6({
|
};
|
||||||
project: projectWithRichBookmarks as Parameters<
|
|
||||||
typeof transformProjectV5ToV6
|
const result = transformProjectV5ToV6({
|
||||||
>[0]["project"],
|
project: projectWithRichBookmarks as Parameters<
|
||||||
});
|
typeof transformProjectV5ToV6
|
||||||
|
>[0]["project"],
|
||||||
expect(result.skipped).toBe(false);
|
});
|
||||||
const mainScene = (
|
|
||||||
result.project.scenes as Array<{ bookmarks: unknown[] }>
|
expect(result.skipped).toBe(false);
|
||||||
)[0];
|
const mainScene = (
|
||||||
expect(mainScene.bookmarks).toEqual([
|
result.project.scenes as Array<{ bookmarks: unknown[] }>
|
||||||
{ time: 1, note: "Intro", color: "#ef4444" },
|
)[0];
|
||||||
{ time: 5.5, duration: 2 },
|
expect(mainScene.bookmarks).toEqual([
|
||||||
]);
|
{ time: 1, note: "Intro", color: "#ef4444" },
|
||||||
});
|
{ time: 5.5, duration: 2 },
|
||||||
});
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue