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";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
buildGraphicParamPath,
|
||||
getKeyframeAtTime,
|
||||
getParamDefaultInterpolation,
|
||||
getParamValueKind,
|
||||
hasKeyframesForPath,
|
||||
upsertPathKeyframe,
|
||||
} from "@/lib/animation";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
} from "@/lib/animation/types";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
|
||||
export interface KeyframedParamPropertyResult {
|
||||
hasAnimatedKeyframes: boolean;
|
||||
isKeyframedAtTime: boolean;
|
||||
keyframeIdAtTime: string | null;
|
||||
onPreview: (value: number | string | boolean) => void;
|
||||
onCommit: () => void;
|
||||
toggleKeyframe: () => void;
|
||||
}
|
||||
|
||||
export function useKeyframedParamProperty({
|
||||
param,
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue,
|
||||
buildBaseUpdates,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedValue: number | string | boolean;
|
||||
buildBaseUpdates: ({
|
||||
value,
|
||||
}: {
|
||||
value: number | string | boolean;
|
||||
}) => Partial<TimelineElement>;
|
||||
}): KeyframedParamPropertyResult {
|
||||
const editor = useEditor();
|
||||
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
});
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
})
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
|
||||
if (shouldUseAnimatedChannel) {
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
animations: upsertPathKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value,
|
||||
valueKind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({
|
||||
param,
|
||||
}),
|
||||
numericRange:
|
||||
param.type === "number"
|
||||
? { min: param.min, max: param.max, step: param.step }
|
||||
: undefined,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: buildBaseUpdates({ value }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId: keyframeIdAtTime,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: resolvedValue,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
hasAnimatedKeyframes,
|
||||
isKeyframedAtTime,
|
||||
keyframeIdAtTime,
|
||||
onPreview: previewValue,
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
toggleKeyframe,
|
||||
};
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
buildGraphicParamPath,
|
||||
coerceAnimationValueForParam,
|
||||
getKeyframeAtTime,
|
||||
getParamDefaultInterpolation,
|
||||
getParamValueKind,
|
||||
hasKeyframesForPath,
|
||||
upsertPathKeyframe,
|
||||
} from "@/lib/animation";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
} from "@/lib/animation/types";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
|
||||
export interface KeyframedParamPropertyResult {
|
||||
hasAnimatedKeyframes: boolean;
|
||||
isKeyframedAtTime: boolean;
|
||||
keyframeIdAtTime: string | null;
|
||||
onPreview: (value: number | string | boolean) => void;
|
||||
onCommit: () => void;
|
||||
toggleKeyframe: () => void;
|
||||
}
|
||||
|
||||
export function useKeyframedParamProperty({
|
||||
param,
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue,
|
||||
buildBaseUpdates,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedValue: number | string | boolean;
|
||||
buildBaseUpdates: ({
|
||||
value,
|
||||
}: {
|
||||
value: number | string | boolean;
|
||||
}) => Partial<TimelineElement>;
|
||||
}): KeyframedParamPropertyResult {
|
||||
const editor = useEditor();
|
||||
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
});
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
})
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
|
||||
if (shouldUseAnimatedChannel) {
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
animations: upsertPathKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value,
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({
|
||||
param,
|
||||
}),
|
||||
coerceValue: (nextValue) =>
|
||||
coerceAnimationValueForParam({
|
||||
param,
|
||||
value: nextValue,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: buildBaseUpdates({ value }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId: keyframeIdAtTime,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: resolvedValue,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
hasAnimatedKeyframes,
|
||||
isKeyframedAtTime,
|
||||
keyframeIdAtTime,
|
||||
onPreview: previewValue,
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
toggleKeyframe,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -292,10 +292,7 @@ export function TimelineElement({
|
|||
const canToggleCurrentSourceAudio =
|
||||
selectedElements.length === 1 &&
|
||||
isCurrentElementSelected &&
|
||||
canToggleSourceAudio({
|
||||
element,
|
||||
mediaAsset,
|
||||
});
|
||||
canToggleSourceAudio(element, mediaAsset);
|
||||
const sourceAudioLabel =
|
||||
element.type === "video"
|
||||
? getSourceAudioActionLabel({ element })
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { OcRippleIcon } from "@/components/icons";
|
||||
|
||||
|
||||
export function TimelineToolbar({
|
||||
zoomLevel,
|
||||
minZoom,
|
||||
|
|
@ -115,10 +116,7 @@ function ToolbarLeftSection() {
|
|||
})();
|
||||
const canToggleSelectedSourceAudio =
|
||||
!!selectedElement &&
|
||||
canToggleSourceAudio({
|
||||
element: selectedElement.element,
|
||||
mediaAsset: selectedMediaAsset,
|
||||
});
|
||||
canToggleSourceAudio(selectedElement.element, selectedMediaAsset);
|
||||
const sourceAudioLabel =
|
||||
selectedElement?.element.type === "video"
|
||||
? getSourceAudioActionLabel({
|
||||
|
|
@ -317,28 +315,34 @@ function ToolbarButton({
|
|||
onClick,
|
||||
disabled,
|
||||
isActive,
|
||||
buttonWrapper,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
tooltip: string;
|
||||
onClick: ({ event }: { event: React.MouseEvent }) => void;
|
||||
onClick?: ({ event }: { event: React.MouseEvent }) => void;
|
||||
disabled?: boolean;
|
||||
isActive?: boolean;
|
||||
buttonWrapper?: (button: React.ReactElement) => React.ReactElement;
|
||||
}) {
|
||||
const button = (
|
||||
<Button
|
||||
variant={isActive ? "secondary" : "text"}
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
onClick={onClick ? (event) => onClick({ event }) : undefined}
|
||||
className={cn(
|
||||
"rounded-sm",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isActive ? "secondary" : "text"}
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
onClick={(event) => onClick({ event })}
|
||||
className={cn(
|
||||
"rounded-sm",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
{buttonWrapper ? buttonWrapper(button) : button}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -294,12 +294,7 @@ export function useEditorActions() {
|
|||
null
|
||||
);
|
||||
})();
|
||||
if (
|
||||
!canToggleSourceAudio({
|
||||
element: selectedElement.element,
|
||||
mediaAsset,
|
||||
})
|
||||
) {
|
||||
if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { getKeyframeById } from "@/lib/animation";
|
||||
import { useKeyframeSelection } from "./use-keyframe-selection";
|
||||
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
|
||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||
|
|
@ -84,10 +85,11 @@ export function useKeyframeDrag({
|
|||
deltaTime: number;
|
||||
}) => {
|
||||
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
|
||||
const channel = element.animations?.channels[keyframeRef.propertyPath];
|
||||
const keyframe = channel?.keyframes.find(
|
||||
(keyframe) => keyframe.id === keyframeRef.keyframeId,
|
||||
);
|
||||
const keyframe = getKeyframeById({
|
||||
animations: element.animations,
|
||||
propertyPath: keyframeRef.propertyPath,
|
||||
keyframeId: keyframeRef.keyframeId,
|
||||
});
|
||||
if (!keyframe) return [];
|
||||
const nextTime = Math.max(
|
||||
0,
|
||||
|
|
|
|||
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 {
|
||||
ElementAnimations,
|
||||
EffectParamPath,
|
||||
NumberAnimationChannel,
|
||||
} from "@/lib/animation/types";
|
||||
import {
|
||||
getChannel,
|
||||
removeKeyframe,
|
||||
setChannel,
|
||||
upsertKeyframe,
|
||||
} from "./keyframes";
|
||||
import { getChannelValueAtTime } from "./interpolation";
|
||||
import { removeElementKeyframe } from "./keyframes";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
export const EFFECT_PARAM_PATH_PREFIX = "effects.";
|
||||
export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
|
||||
|
|
@ -74,64 +68,19 @@ export function resolveEffectParamsAtTime({
|
|||
|
||||
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
|
||||
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
|
||||
const channel = getChannel({ animations, propertyPath: path });
|
||||
if (channel && channel.keyframes.length > 0) {
|
||||
resolved[paramKey] = getChannelValueAtTime({
|
||||
channel,
|
||||
time: localTime,
|
||||
fallbackValue: staticValue,
|
||||
}) as number | string | boolean;
|
||||
} else {
|
||||
resolved[paramKey] = staticValue;
|
||||
}
|
||||
resolved[paramKey] = animations?.bindings[path]
|
||||
? resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: path,
|
||||
localTime,
|
||||
fallbackValue: staticValue,
|
||||
})
|
||||
: staticValue;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = {
|
||||
valueKind: "number",
|
||||
keyframes: [],
|
||||
};
|
||||
|
||||
export function upsertEffectParamKeyframe({
|
||||
animations,
|
||||
effectId,
|
||||
paramKey,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
time: number;
|
||||
value: number;
|
||||
interpolation?: "linear" | "hold";
|
||||
keyframeId?: string;
|
||||
}): ElementAnimations | undefined {
|
||||
const path = buildEffectParamPath({ effectId, paramKey });
|
||||
const channel = getChannel({ animations, propertyPath: path });
|
||||
const targetChannel =
|
||||
channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL;
|
||||
const updatedChannel = upsertKeyframe({
|
||||
channel: targetChannel,
|
||||
time,
|
||||
value,
|
||||
interpolation: interpolation ?? "linear",
|
||||
keyframeId,
|
||||
});
|
||||
|
||||
return (
|
||||
setChannel({
|
||||
animations,
|
||||
propertyPath: path,
|
||||
channel: updatedChannel,
|
||||
}) ?? { channels: {} }
|
||||
);
|
||||
}
|
||||
|
||||
export function removeEffectParamKeyframe({
|
||||
animations,
|
||||
effectId,
|
||||
|
|
@ -143,12 +92,9 @@ export function removeEffectParamKeyframe({
|
|||
paramKey: string;
|
||||
keyframeId: string;
|
||||
}): ElementAnimations | undefined {
|
||||
const path = buildEffectParamPath({ effectId, paramKey });
|
||||
const channel = getChannel({ animations, propertyPath: path });
|
||||
const updatedChannel = removeKeyframe({ channel, keyframeId });
|
||||
return setChannel({
|
||||
return removeElementKeyframe({
|
||||
animations,
|
||||
propertyPath: path,
|
||||
channel: updatedChannel,
|
||||
propertyPath: buildEffectParamPath({ effectId, paramKey }),
|
||||
keyframeId,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,77 +1,73 @@
|
|||
import type {
|
||||
ElementAnimations,
|
||||
GraphicParamPath,
|
||||
} from "@/lib/animation/types";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import {
|
||||
getGraphicDefinition,
|
||||
resolveGraphicParams,
|
||||
} from "@/lib/graphics";
|
||||
import { getChannel } from "./keyframes";
|
||||
import { getChannelValueAtTime } from "./interpolation";
|
||||
|
||||
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||
|
||||
export function buildGraphicParamPath({
|
||||
paramKey,
|
||||
}: {
|
||||
paramKey: string;
|
||||
}): GraphicParamPath {
|
||||
return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
|
||||
}
|
||||
|
||||
export function isGraphicParamPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is GraphicParamPath {
|
||||
return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
|
||||
}
|
||||
|
||||
export function parseGraphicParamPath({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: string;
|
||||
}): { paramKey: string } | null {
|
||||
if (!isGraphicParamPath(propertyPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
|
||||
return paramKey.length > 0 ? { paramKey } : null;
|
||||
}
|
||||
|
||||
export function resolveGraphicParamsAtTime({
|
||||
element,
|
||||
localTime,
|
||||
}: {
|
||||
element: {
|
||||
definitionId: string;
|
||||
params: ParamValues;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const definition = getGraphicDefinition({
|
||||
definitionId: element.definitionId,
|
||||
});
|
||||
const baseParams = resolveGraphicParams(definition, element.params);
|
||||
const resolved: ParamValues = { ...baseParams };
|
||||
|
||||
for (const param of definition.params) {
|
||||
const path = buildGraphicParamPath({ paramKey: param.key });
|
||||
const channel = getChannel({
|
||||
animations: element.animations,
|
||||
propertyPath: path,
|
||||
});
|
||||
if (!channel || channel.keyframes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved[param.key] = getChannelValueAtTime({
|
||||
channel,
|
||||
time: Math.max(0, localTime),
|
||||
fallbackValue: baseParams[param.key] ?? param.default,
|
||||
}) as number | string | boolean;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
import type {
|
||||
ElementAnimations,
|
||||
GraphicParamPath,
|
||||
} from "@/lib/animation/types";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import {
|
||||
getGraphicDefinition,
|
||||
resolveGraphicParams,
|
||||
} from "@/lib/graphics";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||
|
||||
export function buildGraphicParamPath({
|
||||
paramKey,
|
||||
}: {
|
||||
paramKey: string;
|
||||
}): GraphicParamPath {
|
||||
return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
|
||||
}
|
||||
|
||||
export function isGraphicParamPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is GraphicParamPath {
|
||||
return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
|
||||
}
|
||||
|
||||
export function parseGraphicParamPath({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: string;
|
||||
}): { paramKey: string } | null {
|
||||
if (!isGraphicParamPath(propertyPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
|
||||
return paramKey.length > 0 ? { paramKey } : null;
|
||||
}
|
||||
|
||||
export function resolveGraphicParamsAtTime({
|
||||
element,
|
||||
localTime,
|
||||
}: {
|
||||
element: {
|
||||
definitionId: string;
|
||||
params: ParamValues;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const definition = getGraphicDefinition({
|
||||
definitionId: element.definitionId,
|
||||
});
|
||||
const baseParams = resolveGraphicParams(definition, element.params);
|
||||
const resolved: ParamValues = { ...baseParams };
|
||||
|
||||
for (const param of definition.params) {
|
||||
const path = buildGraphicParamPath({ paramKey: param.key });
|
||||
if (!element.animations?.bindings[path]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved[param.key] = resolveAnimationPathValueAtTime({
|
||||
animations: element.animations,
|
||||
propertyPath: path,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseParams[param.key] ?? param.default,
|
||||
});
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +1,81 @@
|
|||
export {
|
||||
getChannelValueAtTime,
|
||||
getNumberChannelValueAtTime,
|
||||
getVectorChannelValueAtTime,
|
||||
normalizeChannel,
|
||||
} from "./interpolation";
|
||||
|
||||
export {
|
||||
clampAnimationsToDuration,
|
||||
cloneAnimations,
|
||||
getChannel,
|
||||
removeElementKeyframe,
|
||||
retimeElementKeyframe,
|
||||
setChannel,
|
||||
splitAnimationsAtTime,
|
||||
upsertElementKeyframe,
|
||||
upsertPathKeyframe,
|
||||
} from "./keyframes";
|
||||
|
||||
export {
|
||||
getElementLocalTime,
|
||||
resolveColorAtTime,
|
||||
resolveNumberAtTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "./resolve";
|
||||
|
||||
export {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getDefaultInterpolationForProperty,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
supportsAnimationProperty,
|
||||
type AnimationPropertyDefinition,
|
||||
type NumericSpec,
|
||||
type NumericRange,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
|
||||
export {
|
||||
getElementKeyframes,
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
} from "./keyframe-query";
|
||||
|
||||
export {
|
||||
buildGraphicParamPath,
|
||||
isGraphicParamPath,
|
||||
parseGraphicParamPath,
|
||||
resolveGraphicParamsAtTime,
|
||||
} from "./graphic-param-channel";
|
||||
|
||||
export {
|
||||
isAnimationPath,
|
||||
resolveAnimationTarget,
|
||||
getParamValueKind,
|
||||
getParamDefaultInterpolation,
|
||||
type AnimationPathDescriptor,
|
||||
} from "./target-resolver";
|
||||
|
||||
export {
|
||||
getGroupKeyframesAtTime,
|
||||
hasGroupKeyframeAtTime,
|
||||
type GroupKeyframeRef,
|
||||
} from "./property-groups";
|
||||
|
||||
export {
|
||||
getVectorChannelForPath,
|
||||
isVectorValue,
|
||||
} from "./vector-channel";
|
||||
export {
|
||||
getChannelValueAtTime,
|
||||
getDiscreteChannelValueAtTime,
|
||||
getScalarChannelValueAtTime,
|
||||
getScalarSegmentInterpolation,
|
||||
normalizeChannel,
|
||||
} from "./interpolation";
|
||||
|
||||
export {
|
||||
clampAnimationsToDuration,
|
||||
cloneAnimations,
|
||||
getChannel,
|
||||
removeElementKeyframe,
|
||||
retimeElementKeyframe,
|
||||
setChannel,
|
||||
splitAnimationsAtTime,
|
||||
upsertElementKeyframe,
|
||||
upsertPathKeyframe,
|
||||
} from "./keyframes";
|
||||
|
||||
export {
|
||||
getElementLocalTime,
|
||||
resolveAnimationPathValueAtTime,
|
||||
resolveColorAtTime,
|
||||
resolveNumberAtTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "./resolve";
|
||||
|
||||
export {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getDefaultInterpolationForProperty,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
supportsAnimationProperty,
|
||||
type AnimationPropertyDefinition,
|
||||
type NumericSpec,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
|
||||
export {
|
||||
getElementKeyframes,
|
||||
getKeyframeById,
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
} from "./keyframe-query";
|
||||
|
||||
export {
|
||||
buildGraphicParamPath,
|
||||
isGraphicParamPath,
|
||||
parseGraphicParamPath,
|
||||
resolveGraphicParamsAtTime,
|
||||
} from "./graphic-param-channel";
|
||||
|
||||
export {
|
||||
buildEffectParamPath,
|
||||
isEffectParamPath,
|
||||
parseEffectParamPath,
|
||||
removeEffectParamKeyframe,
|
||||
resolveEffectParamsAtTime,
|
||||
} from "./effect-param-channel";
|
||||
|
||||
export {
|
||||
isAnimationPath,
|
||||
coerceAnimationValueForParam,
|
||||
resolveAnimationTarget,
|
||||
getParamValueKind,
|
||||
getParamDefaultInterpolation,
|
||||
type AnimationPathDescriptor,
|
||||
} from "./target-resolver";
|
||||
|
||||
export {
|
||||
getGroupKeyframesAtTime,
|
||||
hasGroupKeyframeAtTime,
|
||||
type GroupKeyframeRef,
|
||||
} from "./property-groups";
|
||||
|
||||
export {
|
||||
isVectorValue,
|
||||
} from "./binding-values";
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import type {
|
||||
AnimationChannel,
|
||||
AnimationInterpolation,
|
||||
AnimationValue,
|
||||
ColorAnimationChannel,
|
||||
DiscreteValue,
|
||||
DiscreteAnimationChannel,
|
||||
NumberAnimationChannel,
|
||||
VectorAnimationChannel,
|
||||
VectorValue,
|
||||
DiscreteValue,
|
||||
ScalarAnimationChannel,
|
||||
ScalarAnimationKey,
|
||||
ScalarSegmentType,
|
||||
} from "@/lib/animation/types";
|
||||
import { isVectorValue } from "./vector-channel";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import {
|
||||
getBezierPoint,
|
||||
getDefaultLeftHandle,
|
||||
getDefaultRightHandle,
|
||||
solveBezierProgressForTime,
|
||||
} from "./bezier";
|
||||
|
||||
function byTimeAscending({
|
||||
leftTime,
|
||||
|
|
@ -40,69 +45,6 @@ function clamp01({ value }: { value: number }): number {
|
|||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function parseHexChannel({ hex }: { hex: string }): number | null {
|
||||
const value = Number.parseInt(hex, 16);
|
||||
return Number.isNaN(value) ? null : value;
|
||||
}
|
||||
|
||||
function parseHexColor({
|
||||
color,
|
||||
}: {
|
||||
color: string;
|
||||
}): { red: number; green: number; blue: number; alpha: number } | null {
|
||||
const trimmed = color.trim();
|
||||
if (!trimmed.startsWith("#")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawHex = trimmed.slice(1);
|
||||
if (rawHex.length === 3 || rawHex.length === 4) {
|
||||
const [redHex, greenHex, blueHex, alphaHex = "f"] = rawHex.split("");
|
||||
const red = parseHexChannel({ hex: `${redHex}${redHex}` });
|
||||
const green = parseHexChannel({ hex: `${greenHex}${greenHex}` });
|
||||
const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` });
|
||||
const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` });
|
||||
if (red === null || green === null || blue === null || alpha === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { red, green, blue, alpha: alpha / 255 };
|
||||
}
|
||||
|
||||
if (rawHex.length === 6 || rawHex.length === 8) {
|
||||
const red = parseHexChannel({ hex: rawHex.slice(0, 2) });
|
||||
const green = parseHexChannel({ hex: rawHex.slice(2, 4) });
|
||||
const blue = parseHexChannel({ hex: rawHex.slice(4, 6) });
|
||||
const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff";
|
||||
const alpha = parseHexChannel({ hex: alphaHex });
|
||||
if (red === null || green === null || blue === null || alpha === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { red, green, blue, alpha: alpha / 255 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatRgbaColor({
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
alpha,
|
||||
}: {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
alpha: number;
|
||||
}): string {
|
||||
const roundedRed = Math.round(red);
|
||||
const roundedGreen = Math.round(green);
|
||||
const roundedBlue = Math.round(blue);
|
||||
const roundedAlpha = Math.round(clamp01({ value: alpha }) * 1000) / 1000;
|
||||
return `rgba(${roundedRed}, ${roundedGreen}, ${roundedBlue}, ${roundedAlpha})`;
|
||||
}
|
||||
|
||||
function lerpNumber({
|
||||
leftValue,
|
||||
rightValue,
|
||||
|
|
@ -115,43 +57,99 @@ function lerpNumber({
|
|||
return leftValue + (rightValue - leftValue) * progress;
|
||||
}
|
||||
|
||||
function interpolateColor({
|
||||
leftColor,
|
||||
rightColor,
|
||||
progress,
|
||||
function normalizeRightHandle({
|
||||
handle,
|
||||
leftKey,
|
||||
rightKey,
|
||||
}: {
|
||||
leftColor: string;
|
||||
rightColor: string;
|
||||
progress: number;
|
||||
}): string {
|
||||
const leftParsed = parseHexColor({ color: leftColor });
|
||||
const rightParsed = parseHexColor({ color: rightColor });
|
||||
if (!leftParsed || !rightParsed) {
|
||||
return progress >= 1 ? rightColor : leftColor;
|
||||
handle: ScalarAnimationKey["rightHandle"];
|
||||
leftKey: ScalarAnimationKey;
|
||||
rightKey: ScalarAnimationKey;
|
||||
}) {
|
||||
if (!handle) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return formatRgbaColor({
|
||||
red: lerpNumber({
|
||||
leftValue: leftParsed.red,
|
||||
rightValue: rightParsed.red,
|
||||
progress,
|
||||
}),
|
||||
green: lerpNumber({
|
||||
leftValue: leftParsed.green,
|
||||
rightValue: rightParsed.green,
|
||||
progress,
|
||||
}),
|
||||
blue: lerpNumber({
|
||||
leftValue: leftParsed.blue,
|
||||
rightValue: rightParsed.blue,
|
||||
progress,
|
||||
}),
|
||||
alpha: lerpNumber({
|
||||
leftValue: leftParsed.alpha,
|
||||
rightValue: rightParsed.alpha,
|
||||
progress,
|
||||
}),
|
||||
const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
|
||||
return {
|
||||
dt: Math.min(span, Math.max(0, handle.dt)),
|
||||
dv: handle.dv,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLeftHandle({
|
||||
handle,
|
||||
leftKey,
|
||||
rightKey,
|
||||
}: {
|
||||
handle: ScalarAnimationKey["leftHandle"];
|
||||
leftKey: ScalarAnimationKey;
|
||||
rightKey: ScalarAnimationKey;
|
||||
}) {
|
||||
if (!handle) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
|
||||
return {
|
||||
dt: Math.max(-span, Math.min(0, handle.dt)),
|
||||
dv: handle.dv,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeScalarKey({
|
||||
key,
|
||||
}: {
|
||||
key: ScalarAnimationKey;
|
||||
}): ScalarAnimationKey {
|
||||
return {
|
||||
...key,
|
||||
tangentMode: key.tangentMode ?? "flat",
|
||||
segmentToNext: key.segmentToNext ?? "linear",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeScalarChannel({
|
||||
channel,
|
||||
}: {
|
||||
channel: ScalarAnimationChannel;
|
||||
}): ScalarAnimationChannel {
|
||||
const sortedKeys = [...channel.keys]
|
||||
.map((key) => normalizeScalarKey({ key }))
|
||||
.sort((leftKey, rightKey) =>
|
||||
byTimeAscending({
|
||||
leftTime: leftKey.time,
|
||||
rightTime: rightKey.time,
|
||||
}),
|
||||
);
|
||||
const nextKeys = sortedKeys.map((key, index) => {
|
||||
const previousKey = sortedKeys[index - 1];
|
||||
const nextKey = sortedKeys[index + 1];
|
||||
return {
|
||||
...key,
|
||||
leftHandle:
|
||||
previousKey != null
|
||||
? normalizeLeftHandle({
|
||||
handle: key.leftHandle,
|
||||
leftKey: previousKey,
|
||||
rightKey: key,
|
||||
})
|
||||
: undefined,
|
||||
rightHandle:
|
||||
nextKey != null
|
||||
? normalizeRightHandle({
|
||||
handle: key.rightHandle,
|
||||
leftKey: key,
|
||||
rightKey: nextKey,
|
||||
})
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...channel,
|
||||
keys: nextKeys,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeChannel<TChannel extends AnimationChannel>({
|
||||
|
|
@ -159,9 +157,15 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
|
|||
}: {
|
||||
channel: TChannel;
|
||||
}): TChannel {
|
||||
if (channel.kind === "scalar") {
|
||||
return normalizeScalarChannel({
|
||||
channel,
|
||||
}) as TChannel;
|
||||
}
|
||||
|
||||
return {
|
||||
...channel,
|
||||
keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) =>
|
||||
keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
|
||||
byTimeAscending({
|
||||
leftTime: leftKeyframe.time,
|
||||
rightTime: rightKeyframe.time,
|
||||
|
|
@ -170,171 +174,152 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
|
|||
} as TChannel;
|
||||
}
|
||||
|
||||
function evaluateChannelValueAtTime<
|
||||
TKeyframe extends { time: number; value: TValue },
|
||||
TValue,
|
||||
>({
|
||||
keyframes,
|
||||
function extrapolateScalarEdge({
|
||||
mode,
|
||||
edgeKey,
|
||||
neighborKey,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue,
|
||||
}: {
|
||||
keyframes: TKeyframe[] | undefined;
|
||||
mode: "hold" | "linear";
|
||||
edgeKey: ScalarAnimationKey;
|
||||
neighborKey: ScalarAnimationKey | undefined;
|
||||
time: number;
|
||||
fallbackValue: TValue;
|
||||
getInterpolatedValue: ({
|
||||
leftKeyframe,
|
||||
rightKeyframe,
|
||||
progress,
|
||||
}: {
|
||||
leftKeyframe: TKeyframe;
|
||||
rightKeyframe: TKeyframe;
|
||||
progress: number;
|
||||
}) => TValue;
|
||||
}): TValue {
|
||||
if (!keyframes || keyframes.length === 0) {
|
||||
return fallbackValue;
|
||||
}) {
|
||||
if (mode === "hold" || !neighborKey) {
|
||||
return edgeKey.value;
|
||||
}
|
||||
|
||||
const firstKeyframe = keyframes[0];
|
||||
const lastKeyframe = keyframes[keyframes.length - 1];
|
||||
if (!firstKeyframe || !lastKeyframe) {
|
||||
return fallbackValue;
|
||||
const span = neighborKey.time - edgeKey.time;
|
||||
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
||||
return edgeKey.value;
|
||||
}
|
||||
|
||||
if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) {
|
||||
return firstKeyframe.value;
|
||||
}
|
||||
|
||||
if (time >= lastKeyframe.time - TIME_EPSILON_SECONDS) {
|
||||
return lastKeyframe.value;
|
||||
}
|
||||
|
||||
for (
|
||||
let keyframeIndex = 0;
|
||||
keyframeIndex < keyframes.length - 1;
|
||||
keyframeIndex++
|
||||
) {
|
||||
const leftKeyframe = keyframes[keyframeIndex];
|
||||
const rightKeyframe = keyframes[keyframeIndex + 1];
|
||||
|
||||
if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) {
|
||||
return rightKeyframe.value;
|
||||
}
|
||||
|
||||
const isBetweenPair = isWithinTimePair({
|
||||
time,
|
||||
leftTime: leftKeyframe.time,
|
||||
rightTime: rightKeyframe.time,
|
||||
});
|
||||
if (!isBetweenPair) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const span = rightKeyframe.time - leftKeyframe.time;
|
||||
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
||||
return rightKeyframe.value;
|
||||
}
|
||||
|
||||
const progress = clamp01({
|
||||
value: (time - leftKeyframe.time) / span,
|
||||
});
|
||||
|
||||
return getInterpolatedValue({
|
||||
leftKeyframe,
|
||||
rightKeyframe,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
|
||||
return lastKeyframe.value;
|
||||
return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value);
|
||||
}
|
||||
|
||||
export function getNumberChannelValueAtTime({
|
||||
export function getScalarSegmentInterpolation({
|
||||
segment,
|
||||
}: {
|
||||
segment: ScalarSegmentType;
|
||||
}): AnimationInterpolation {
|
||||
if (segment === "step") {
|
||||
return "hold";
|
||||
}
|
||||
|
||||
return segment === "bezier" ? "bezier" : "linear";
|
||||
}
|
||||
|
||||
export function getScalarChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: NumberAnimationChannel | undefined;
|
||||
channel: ScalarAnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: number;
|
||||
}): number {
|
||||
return evaluateChannelValueAtTime({
|
||||
keyframes: channel?.keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
|
||||
if (leftKeyframe.interpolation === "hold") {
|
||||
return leftKeyframe.value;
|
||||
}
|
||||
if (!channel || channel.keys.length === 0) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const normalizedChannel = normalizeChannel({
|
||||
channel,
|
||||
});
|
||||
const firstKey = normalizedChannel.keys[0];
|
||||
const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
|
||||
if (!firstKey || !lastKey) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
if (time <= firstKey.time + TIME_EPSILON_SECONDS) {
|
||||
if (time < firstKey.time - TIME_EPSILON_SECONDS) {
|
||||
return extrapolateScalarEdge({
|
||||
mode: normalizedChannel.extrapolation?.before ?? "hold",
|
||||
edgeKey: firstKey,
|
||||
neighborKey: normalizedChannel.keys[1],
|
||||
time,
|
||||
});
|
||||
}
|
||||
|
||||
return firstKey.value;
|
||||
}
|
||||
|
||||
if (time >= lastKey.time - TIME_EPSILON_SECONDS) {
|
||||
if (time > lastKey.time + TIME_EPSILON_SECONDS) {
|
||||
return extrapolateScalarEdge({
|
||||
mode: normalizedChannel.extrapolation?.after ?? "hold",
|
||||
edgeKey: lastKey,
|
||||
neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2],
|
||||
time,
|
||||
});
|
||||
}
|
||||
|
||||
return lastKey.value;
|
||||
}
|
||||
|
||||
for (
|
||||
let keyIndex = 0;
|
||||
keyIndex < normalizedChannel.keys.length - 1;
|
||||
keyIndex++
|
||||
) {
|
||||
const leftKey = normalizedChannel.keys[keyIndex];
|
||||
const rightKey = normalizedChannel.keys[keyIndex + 1];
|
||||
if (Math.abs(time - rightKey.time) <= TIME_EPSILON_SECONDS) {
|
||||
return rightKey.value;
|
||||
}
|
||||
|
||||
if (
|
||||
!isWithinTimePair({
|
||||
time,
|
||||
leftTime: leftKey.time,
|
||||
rightTime: rightKey.time,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (leftKey.segmentToNext === "step") {
|
||||
return leftKey.value;
|
||||
}
|
||||
|
||||
const span = rightKey.time - leftKey.time;
|
||||
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
||||
return rightKey.value;
|
||||
}
|
||||
|
||||
const progress = clamp01({
|
||||
value: (time - leftKey.time) / span,
|
||||
});
|
||||
if (leftKey.segmentToNext === "linear") {
|
||||
return lerpNumber({
|
||||
leftValue: leftKeyframe.value,
|
||||
rightValue: rightKeyframe.value,
|
||||
leftValue: leftKey.value,
|
||||
rightValue: rightKey.value,
|
||||
progress,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const curveProgress = solveBezierProgressForTime({
|
||||
time,
|
||||
leftKey,
|
||||
rightKey,
|
||||
});
|
||||
const rightHandle =
|
||||
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
|
||||
const leftHandle =
|
||||
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
|
||||
return getBezierPoint({
|
||||
progress: curveProgress,
|
||||
p0: leftKey.value,
|
||||
p1: leftKey.value + rightHandle.dv,
|
||||
p2: rightKey.value + leftHandle.dv,
|
||||
p3: rightKey.value,
|
||||
});
|
||||
}
|
||||
|
||||
return lastKey.value;
|
||||
}
|
||||
|
||||
export function getColorValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: ColorAnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: string;
|
||||
}): string {
|
||||
return evaluateChannelValueAtTime({
|
||||
keyframes: channel?.keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
|
||||
if (leftKeyframe.interpolation === "hold") {
|
||||
return leftKeyframe.value;
|
||||
}
|
||||
|
||||
return interpolateColor({
|
||||
leftColor: leftKeyframe.value,
|
||||
rightColor: rightKeyframe.value,
|
||||
progress,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getVectorChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: VectorAnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: VectorValue;
|
||||
}): VectorValue {
|
||||
return evaluateChannelValueAtTime({
|
||||
keyframes: channel?.keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
|
||||
if (leftKeyframe.interpolation === "hold") {
|
||||
return leftKeyframe.value;
|
||||
}
|
||||
|
||||
return {
|
||||
x:
|
||||
leftKeyframe.value.x +
|
||||
(rightKeyframe.value.x - leftKeyframe.value.x) * progress,
|
||||
y:
|
||||
leftKeyframe.value.y +
|
||||
(rightKeyframe.value.y - leftKeyframe.value.y) * progress,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getDiscreteValueAtTime({
|
||||
export function getDiscreteChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
|
|
@ -343,12 +328,21 @@ function getDiscreteValueAtTime({
|
|||
time: number;
|
||||
fallbackValue: DiscreteValue;
|
||||
}): DiscreteValue {
|
||||
return evaluateChannelValueAtTime({
|
||||
keyframes: channel?.keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value,
|
||||
if (!channel || channel.keys.length === 0) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const normalizedChannel = normalizeChannel({
|
||||
channel,
|
||||
});
|
||||
let currentValue = fallbackValue;
|
||||
for (const key of normalizedChannel.keys) {
|
||||
if (time + TIME_EPSILON_SECONDS < key.time) {
|
||||
break;
|
||||
}
|
||||
currentValue = key.value;
|
||||
}
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
export function getChannelValueAtTime({
|
||||
|
|
@ -360,50 +354,25 @@ export function getChannelValueAtTime({
|
|||
time: number;
|
||||
fallbackValue: AnimationValue;
|
||||
}): AnimationValue {
|
||||
if (!channel || channel.keyframes.length === 0) {
|
||||
if (!channel || channel.keys.length === 0) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
if (channel.valueKind === "number") {
|
||||
if (typeof fallbackValue !== "number") {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return getNumberChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
});
|
||||
}
|
||||
|
||||
if (channel.valueKind === "color") {
|
||||
if (typeof fallbackValue !== "string") {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return getColorValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
});
|
||||
}
|
||||
|
||||
if (channel.valueKind === "vector") {
|
||||
if (!isVectorValue(fallbackValue)) {
|
||||
return fallbackValue;
|
||||
}
|
||||
return getVectorChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue: fallbackValue as VectorValue,
|
||||
});
|
||||
if (channel.kind === "scalar") {
|
||||
return typeof fallbackValue === "number"
|
||||
? getScalarChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
})
|
||||
: fallbackValue;
|
||||
}
|
||||
|
||||
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return getDiscreteValueAtTime({
|
||||
return getDiscreteChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
|
|
|
|||
|
|
@ -1,72 +1,298 @@
|
|||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import type {
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
ElementKeyframe,
|
||||
} from "@/lib/animation/types";
|
||||
import { isAnimationPath } from "./target-resolver";
|
||||
|
||||
export function getElementKeyframes({
|
||||
animations,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
}): ElementKeyframe[] {
|
||||
if (!animations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(animations.channels).flatMap(
|
||||
([propertyPath, channel]) => {
|
||||
if (
|
||||
!channel ||
|
||||
channel.keyframes.length === 0 ||
|
||||
!isAnimationPath(propertyPath)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return channel.keyframes.map((keyframe) => ({
|
||||
propertyPath,
|
||||
id: keyframe.id,
|
||||
time: keyframe.time,
|
||||
value: keyframe.value,
|
||||
interpolation: keyframe.interpolation,
|
||||
}));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
}): boolean {
|
||||
const channel = animations?.channels[propertyPath];
|
||||
return Boolean(channel && channel.keyframes.length > 0);
|
||||
}
|
||||
|
||||
export function getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
time: number;
|
||||
}): ElementKeyframe | null {
|
||||
const channel = animations?.channels[propertyPath];
|
||||
if (!channel || channel.keyframes.length === 0) return null;
|
||||
const keyframe = channel.keyframes.find(
|
||||
(keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
|
||||
);
|
||||
if (!keyframe) return null;
|
||||
return {
|
||||
propertyPath,
|
||||
id: keyframe.id,
|
||||
time: keyframe.time,
|
||||
value: keyframe.value,
|
||||
interpolation: keyframe.interpolation,
|
||||
};
|
||||
}
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import type {
|
||||
AnimationBindingInstance,
|
||||
AnimationChannel,
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
ElementKeyframe,
|
||||
} from "@/lib/animation/types";
|
||||
import {
|
||||
type AnimationComponentValue,
|
||||
composeAnimationValue,
|
||||
} from "./binding-values";
|
||||
import {
|
||||
getChannelValueAtTime,
|
||||
getScalarSegmentInterpolation,
|
||||
} from "./interpolation";
|
||||
import { isAnimationPath } from "./target-resolver";
|
||||
|
||||
function getBindingFallbackValue({
|
||||
channel,
|
||||
}: {
|
||||
channel: ElementAnimations["channels"][string];
|
||||
}) {
|
||||
if (!channel || channel.keys.length === 0) {
|
||||
return channel?.kind === "discrete" ? false : 0;
|
||||
}
|
||||
|
||||
return channel.keys[0].value;
|
||||
}
|
||||
|
||||
interface BindingKeyframeMatch {
|
||||
componentIndex: number;
|
||||
channel: AnimationChannel;
|
||||
keyframe: AnimationChannel["keys"][number];
|
||||
}
|
||||
|
||||
function getBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
}: {
|
||||
animations: ElementAnimations;
|
||||
binding: AnimationBindingInstance;
|
||||
}): BindingKeyframeMatch[] {
|
||||
return binding.components.flatMap((component, componentIndex) => {
|
||||
const channel = animations.channels[component.channelId];
|
||||
if (!channel || channel.keys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return channel.keys.map((keyframe) => ({
|
||||
componentIndex,
|
||||
channel,
|
||||
keyframe,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function getUniqueBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
}: {
|
||||
animations: ElementAnimations;
|
||||
binding: AnimationBindingInstance;
|
||||
}): BindingKeyframeMatch[] {
|
||||
const sortedMatches = getBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
}).sort(
|
||||
(leftMatch, rightMatch) =>
|
||||
leftMatch.keyframe.time - rightMatch.keyframe.time ||
|
||||
leftMatch.componentIndex - rightMatch.componentIndex,
|
||||
);
|
||||
const uniqueMatches: BindingKeyframeMatch[] = [];
|
||||
|
||||
for (const match of sortedMatches) {
|
||||
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
|
||||
if (
|
||||
!previousMatch ||
|
||||
Math.abs(previousMatch.keyframe.time - match.keyframe.time) >
|
||||
TIME_EPSILON_SECONDS
|
||||
) {
|
||||
uniqueMatches.push(match);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
previousMatch.componentIndex !== 0 &&
|
||||
match.componentIndex === 0
|
||||
) {
|
||||
uniqueMatches[uniqueMatches.length - 1] = match;
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueMatches;
|
||||
}
|
||||
|
||||
function getPreferredBindingKeyframeMatch({
|
||||
matches,
|
||||
}: {
|
||||
matches: BindingKeyframeMatch[];
|
||||
}): BindingKeyframeMatch | null {
|
||||
return (
|
||||
matches.find((match) => match.componentIndex === 0) ??
|
||||
matches[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function getComposedBindingValueAtTime({
|
||||
animations,
|
||||
binding,
|
||||
time,
|
||||
}: {
|
||||
animations: ElementAnimations;
|
||||
binding: AnimationBindingInstance;
|
||||
time: number;
|
||||
}) {
|
||||
const componentValues = Object.fromEntries(
|
||||
binding.components.map((component) => {
|
||||
const channel = animations.channels[component.channelId];
|
||||
return [
|
||||
component.key,
|
||||
getChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue: getBindingFallbackValue({ channel }),
|
||||
}),
|
||||
];
|
||||
}),
|
||||
) as Record<string, AnimationComponentValue | undefined>;
|
||||
|
||||
return composeAnimationValue({
|
||||
binding,
|
||||
componentValues,
|
||||
});
|
||||
}
|
||||
|
||||
function getKeyframeInterpolation({
|
||||
channel,
|
||||
keyframe,
|
||||
}: {
|
||||
channel: AnimationChannel;
|
||||
keyframe: AnimationChannel["keys"][number];
|
||||
}) {
|
||||
return channel.kind === "scalar" && "segmentToNext" in keyframe
|
||||
? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
|
||||
: "hold";
|
||||
}
|
||||
|
||||
function toElementKeyframe({
|
||||
animations,
|
||||
binding,
|
||||
propertyPath,
|
||||
keyframeMatch,
|
||||
}: {
|
||||
animations: ElementAnimations;
|
||||
binding: AnimationBindingInstance;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeMatch: BindingKeyframeMatch;
|
||||
}): ElementKeyframe | null {
|
||||
const value = getComposedBindingValueAtTime({
|
||||
animations,
|
||||
binding,
|
||||
time: keyframeMatch.keyframe.time,
|
||||
});
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
propertyPath,
|
||||
id: keyframeMatch.keyframe.id,
|
||||
time: keyframeMatch.keyframe.time,
|
||||
value,
|
||||
interpolation: getKeyframeInterpolation({
|
||||
channel: keyframeMatch.channel,
|
||||
keyframe: keyframeMatch.keyframe,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getElementKeyframes({
|
||||
animations,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
}): ElementKeyframe[] {
|
||||
if (!animations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(animations.bindings).flatMap(
|
||||
([propertyPath, binding]) => {
|
||||
if (!binding || !isAnimationPath(propertyPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getUniqueBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
}).flatMap((keyframeMatch) => {
|
||||
const keyframe = toElementKeyframe({
|
||||
animations,
|
||||
binding,
|
||||
propertyPath,
|
||||
keyframeMatch,
|
||||
});
|
||||
if (!keyframe) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [keyframe];
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
}): boolean {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return binding.components.some((component) =>
|
||||
Boolean(animations?.channels[component.channelId]?.keys.length),
|
||||
);
|
||||
}
|
||||
|
||||
export function getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
time: number;
|
||||
}): ElementKeyframe | null {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyframeMatch = getPreferredBindingKeyframeMatch({
|
||||
matches: getBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
}).filter(({ keyframe }) =>
|
||||
Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
|
||||
),
|
||||
});
|
||||
if (!keyframeMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toElementKeyframe({
|
||||
animations,
|
||||
binding,
|
||||
propertyPath,
|
||||
keyframeMatch,
|
||||
});
|
||||
}
|
||||
|
||||
export function getKeyframeById({
|
||||
animations,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
}): ElementKeyframe | null {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyframeMatch = getPreferredBindingKeyframeMatch({
|
||||
matches: getBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
}).filter(({ keyframe }) => keyframe.id === keyframeId),
|
||||
});
|
||||
if (!keyframeMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toElementKeyframe({
|
||||
animations,
|
||||
binding,
|
||||
propertyPath,
|
||||
keyframeMatch,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
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 {
|
||||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
AnimationValueKind,
|
||||
DiscreteValue,
|
||||
VectorValue,
|
||||
} from "@/lib/animation/types";
|
||||
import { isVectorValue } from "./vector-channel";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
} from "@/constants/text-constants";
|
||||
import {
|
||||
canElementHaveAudio,
|
||||
isVisualElement,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
|
||||
export interface NumericSpec {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export type NumericRange = NumericSpec;
|
||||
|
||||
export interface AnimationPropertyDefinition {
|
||||
valueKind: AnimationValueKind;
|
||||
defaultInterpolation: AnimationInterpolation;
|
||||
numericRange?: NumericSpec;
|
||||
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
||||
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
|
||||
setValue: ({
|
||||
element,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
value: AnimationValue;
|
||||
}) => TimelineElement;
|
||||
}
|
||||
|
||||
const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
AnimationPropertyPath,
|
||||
AnimationPropertyDefinition
|
||||
> = {
|
||||
"transform.position": {
|
||||
valueKind: "vector",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.position : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: value as VectorValue,
|
||||
},
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"transform.scaleX": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.scaleX : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, scaleX: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"transform.scaleY": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.scaleY : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, scaleY: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"transform.rotate": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: -360, max: 360, step: 1 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.rotate : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, rotate: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
opacity: {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: 0, max: 1, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.opacity : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? { ...element, opacity: value as number }
|
||||
: element,
|
||||
},
|
||||
volume: {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
|
||||
supportsElement: ({ element }) => canElementHaveAudio(element),
|
||||
getValue: ({ element }) =>
|
||||
canElementHaveAudio(element) ? element.volume ?? 0 : null,
|
||||
setValue: ({ element, value }) =>
|
||||
canElementHaveAudio(element)
|
||||
? { ...element, volume: value as number }
|
||||
: element,
|
||||
},
|
||||
color: {
|
||||
valueKind: "color",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) => (element.type === "text" ? element.color : null),
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? { ...element, color: value as string }
|
||||
: element,
|
||||
},
|
||||
"background.color": {
|
||||
valueKind: "color",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text" ? element.background.color : null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, color: value as string },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.paddingX": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: 0, step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, paddingX: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.paddingY": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: 0, step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, paddingY: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.offsetX": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, offsetX: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.offsetY": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, offsetY: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.cornerRadius": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, cornerRadius: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
};
|
||||
|
||||
export function isAnimationPropertyPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPropertyPath {
|
||||
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
|
||||
}
|
||||
|
||||
export function getAnimationPropertyDefinition({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationPropertyDefinition {
|
||||
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
|
||||
}
|
||||
|
||||
export function supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): boolean {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.supportsElement({ element });
|
||||
}
|
||||
|
||||
export function getElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationValue | null {
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
if (!definition.supportsElement({ element })) {
|
||||
return null;
|
||||
}
|
||||
return definition.getValue({ element });
|
||||
}
|
||||
|
||||
export function withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
}): TimelineElement {
|
||||
const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
if (!definition.supportsElement({ element })) {
|
||||
return element;
|
||||
}
|
||||
return definition.setValue({ element, value: coercedValue });
|
||||
}
|
||||
|
||||
export function getDefaultInterpolationForProperty({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationInterpolation {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.defaultInterpolation;
|
||||
}
|
||||
|
||||
function applyNumericSpec({
|
||||
value,
|
||||
numericRange,
|
||||
}: {
|
||||
value: number;
|
||||
numericRange: NumericSpec | undefined;
|
||||
}): number {
|
||||
if (!numericRange) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const steppedValue =
|
||||
numericRange.step != null
|
||||
? snapToStep({ value, step: numericRange.step })
|
||||
: value;
|
||||
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
|
||||
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||
}
|
||||
|
||||
export function coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
}): AnimationValue | null {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
|
||||
if (propertyDefinition.valueKind === "number") {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return applyNumericSpec({
|
||||
value,
|
||||
numericRange: propertyDefinition.numericRange,
|
||||
});
|
||||
}
|
||||
|
||||
if (propertyDefinition.valueKind === "color") {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
if (propertyDefinition.valueKind === "vector") {
|
||||
return isVectorValue(value) ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value === "string" || typeof value === "boolean") {
|
||||
return value as DiscreteValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
import type {
|
||||
AnimationBindingKind,
|
||||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
VectorValue,
|
||||
} from "@/lib/animation/types";
|
||||
import { isVectorValue, parseColorToLinearRgba } from "./binding-values";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
} from "@/constants/text-constants";
|
||||
import {
|
||||
canElementHaveAudio,
|
||||
isVisualElement,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
|
||||
export interface NumericSpec {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface AnimationPropertyDefinition {
|
||||
kind: AnimationBindingKind;
|
||||
defaultInterpolation: AnimationInterpolation;
|
||||
numericRanges?: Partial<Record<string, NumericSpec>>;
|
||||
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
||||
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
|
||||
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
|
||||
setValue: ({
|
||||
element,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
value: AnimationValue;
|
||||
}) => TimelineElement;
|
||||
}
|
||||
|
||||
function applyNumericSpec({
|
||||
value,
|
||||
numericRange,
|
||||
}: {
|
||||
value: number;
|
||||
numericRange: NumericSpec | undefined;
|
||||
}): number {
|
||||
if (!numericRange) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const steppedValue =
|
||||
numericRange.step != null
|
||||
? snapToStep({ value, step: numericRange.step })
|
||||
: value;
|
||||
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
|
||||
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||
}
|
||||
|
||||
function coerceNumberValue({
|
||||
value,
|
||||
numericRange,
|
||||
}: {
|
||||
value: AnimationValue;
|
||||
numericRange?: NumericSpec;
|
||||
}): number | null {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return applyNumericSpec({ value, numericRange });
|
||||
}
|
||||
|
||||
function coerceColorValue({
|
||||
value,
|
||||
}: {
|
||||
value: AnimationValue;
|
||||
}): string | null {
|
||||
return typeof value === "string" && parseColorToLinearRgba({ color: value })
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function createNumberPropertyDefinition({
|
||||
numericRange,
|
||||
supportsElement,
|
||||
getValue,
|
||||
setValue,
|
||||
}: {
|
||||
numericRange?: NumericSpec;
|
||||
supportsElement: AnimationPropertyDefinition["supportsElement"];
|
||||
getValue: AnimationPropertyDefinition["getValue"];
|
||||
setValue: AnimationPropertyDefinition["setValue"];
|
||||
}): AnimationPropertyDefinition {
|
||||
return {
|
||||
kind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRanges: numericRange ? { value: numericRange } : undefined,
|
||||
supportsElement,
|
||||
getValue,
|
||||
coerceValue: ({ value }) =>
|
||||
coerceNumberValue({
|
||||
value,
|
||||
numericRange,
|
||||
}),
|
||||
setValue,
|
||||
};
|
||||
}
|
||||
|
||||
const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
AnimationPropertyPath,
|
||||
AnimationPropertyDefinition
|
||||
> = {
|
||||
"transform.position": {
|
||||
kind: "vector2",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.position : null,
|
||||
coerceValue: ({ value }) => (isVectorValue(value) ? value : null),
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: value as VectorValue,
|
||||
},
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"transform.scaleX": createNumberPropertyDefinition({
|
||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.scaleX : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, scaleX: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"transform.scaleY": createNumberPropertyDefinition({
|
||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.scaleY : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, scaleY: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"transform.rotate": createNumberPropertyDefinition({
|
||||
numericRange: { min: -360, max: 360, step: 1 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.rotate : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, rotate: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
opacity: createNumberPropertyDefinition({
|
||||
numericRange: { min: 0, max: 1, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.opacity : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? { ...element, opacity: value as number }
|
||||
: element,
|
||||
}),
|
||||
volume: createNumberPropertyDefinition({
|
||||
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
|
||||
supportsElement: ({ element }) => canElementHaveAudio(element),
|
||||
getValue: ({ element }) =>
|
||||
canElementHaveAudio(element) ? element.volume ?? 0 : null,
|
||||
setValue: ({ element, value }) =>
|
||||
canElementHaveAudio(element)
|
||||
? { ...element, volume: value as number }
|
||||
: element,
|
||||
}),
|
||||
color: {
|
||||
kind: "color",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) => (element.type === "text" ? element.color : null),
|
||||
coerceValue: ({ value }) => coerceColorValue({ value }),
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? { ...element, color: value as string }
|
||||
: element,
|
||||
},
|
||||
"background.color": {
|
||||
kind: "color",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text" ? element.background.color : null,
|
||||
coerceValue: ({ value }) => coerceColorValue({ value }),
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, color: value as string },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.paddingX": createNumberPropertyDefinition({
|
||||
numericRange: { min: 0, step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, paddingX: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.paddingY": createNumberPropertyDefinition({
|
||||
numericRange: { min: 0, step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, paddingY: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.offsetX": createNumberPropertyDefinition({
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, offsetX: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.offsetY": createNumberPropertyDefinition({
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, offsetY: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.cornerRadius": createNumberPropertyDefinition({
|
||||
numericRange: {
|
||||
min: CORNER_RADIUS_MIN,
|
||||
max: CORNER_RADIUS_MAX,
|
||||
step: 1,
|
||||
},
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, cornerRadius: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
};
|
||||
|
||||
export function isAnimationPropertyPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPropertyPath {
|
||||
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
|
||||
}
|
||||
|
||||
export function getAnimationPropertyDefinition({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationPropertyDefinition {
|
||||
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
|
||||
}
|
||||
|
||||
export function supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): boolean {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.supportsElement({ element });
|
||||
}
|
||||
|
||||
export function getElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationValue | null {
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
if (!definition.supportsElement({ element })) {
|
||||
return null;
|
||||
}
|
||||
return definition.getValue({ element });
|
||||
}
|
||||
|
||||
export function withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
}): TimelineElement {
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
const coercedValue = definition.coerceValue({ value });
|
||||
if (coercedValue === null || !definition.supportsElement({ element })) {
|
||||
return element;
|
||||
}
|
||||
return definition.setValue({ element, value: coercedValue });
|
||||
}
|
||||
|
||||
export function getDefaultInterpolationForProperty({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationInterpolation {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.defaultInterpolation;
|
||||
}
|
||||
|
||||
export function coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
}): AnimationValue | null {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.coerceValue({ value });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,135 +1,174 @@
|
|||
import type {
|
||||
AnimationPropertyPath,
|
||||
ElementAnimations,
|
||||
} from "@/lib/animation/types";
|
||||
import type { Transform } from "@/lib/rendering";
|
||||
import {
|
||||
getColorValueAtTime,
|
||||
getNumberChannelValueAtTime,
|
||||
getVectorChannelValueAtTime,
|
||||
} from "./interpolation";
|
||||
import { getColorChannelForPath } from "./color-channel";
|
||||
import { getNumberChannelForPath } from "./number-channel";
|
||||
import { getVectorChannelForPath } from "./vector-channel";
|
||||
|
||||
export function getElementLocalTime({
|
||||
timelineTime,
|
||||
elementStartTime,
|
||||
elementDuration,
|
||||
}: {
|
||||
timelineTime: number;
|
||||
elementStartTime: number;
|
||||
elementDuration: number;
|
||||
}): number {
|
||||
const localTime = timelineTime - elementStartTime;
|
||||
if (localTime <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (localTime >= elementDuration) {
|
||||
return elementDuration;
|
||||
}
|
||||
|
||||
return localTime;
|
||||
}
|
||||
|
||||
export function resolveTransformAtTime({
|
||||
baseTransform,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseTransform: Transform;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): Transform {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
return {
|
||||
position: getVectorChannelValueAtTime({
|
||||
channel: getVectorChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.position",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.position,
|
||||
}),
|
||||
scaleX: getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleX,
|
||||
}),
|
||||
scaleY: getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleY,
|
||||
}),
|
||||
rotate: getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.rotate",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.rotate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveOpacityAtTime({
|
||||
baseOpacity,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseOpacity: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "opacity",
|
||||
}),
|
||||
time: Math.max(0, localTime),
|
||||
fallbackValue: baseOpacity,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNumberAtTime({
|
||||
baseValue,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseValue: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({ animations, propertyPath }),
|
||||
time: Math.max(0, localTime),
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveColorAtTime({
|
||||
baseColor,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseColor: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
}): string {
|
||||
return getColorValueAtTime({
|
||||
channel: getColorChannelForPath({ animations, propertyPath }),
|
||||
time: Math.max(0, localTime),
|
||||
fallbackValue: baseColor,
|
||||
});
|
||||
}
|
||||
import type {
|
||||
AnimationPropertyPath,
|
||||
ElementAnimations,
|
||||
} from "@/lib/animation/types";
|
||||
import type { Transform } from "@/lib/rendering";
|
||||
import {
|
||||
type AnimationComponentValue,
|
||||
composeAnimationValue,
|
||||
decomposeAnimationValue,
|
||||
} from "./binding-values";
|
||||
import {
|
||||
getChannelValueAtTime,
|
||||
} from "./interpolation";
|
||||
|
||||
export function getElementLocalTime({
|
||||
timelineTime,
|
||||
elementStartTime,
|
||||
elementDuration,
|
||||
}: {
|
||||
timelineTime: number;
|
||||
elementStartTime: number;
|
||||
elementDuration: number;
|
||||
}): number {
|
||||
const localTime = timelineTime - elementStartTime;
|
||||
if (localTime <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (localTime >= elementDuration) {
|
||||
return elementDuration;
|
||||
}
|
||||
|
||||
return localTime;
|
||||
}
|
||||
|
||||
export function resolveTransformAtTime({
|
||||
baseTransform,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseTransform: Transform;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): Transform {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
return {
|
||||
position: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.position",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.position,
|
||||
}),
|
||||
scaleX: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleX,
|
||||
}),
|
||||
scaleY: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleY,
|
||||
}),
|
||||
rotate: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.rotate",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.rotate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveOpacityAtTime({
|
||||
baseOpacity,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseOpacity: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "opacity",
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseOpacity,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNumberAtTime({
|
||||
baseValue,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseValue: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveColorAtTime({
|
||||
baseColor,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseColor: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
}): string {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseColor,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAnimationPathValueAtTime<
|
||||
T extends number | string | boolean | Transform["position"],
|
||||
>({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
fallbackValue,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: string;
|
||||
localTime: number;
|
||||
fallbackValue: T;
|
||||
}): T {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const fallbackComponents = decomposeAnimationValue({
|
||||
kind: binding.kind,
|
||||
value: fallbackValue,
|
||||
});
|
||||
if (!fallbackComponents) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const componentValues = Object.fromEntries(
|
||||
binding.components.map((component) => {
|
||||
const channel = animations?.channels[component.channelId];
|
||||
return [
|
||||
component.key,
|
||||
getChannelValueAtTime({
|
||||
channel,
|
||||
time: localTime,
|
||||
fallbackValue:
|
||||
fallbackComponents[component.key] ??
|
||||
(channel?.kind === "discrete" ? false : 0),
|
||||
}),
|
||||
];
|
||||
}),
|
||||
) as Record<string, AnimationComponentValue | undefined>;
|
||||
return (composeAnimationValue({
|
||||
binding,
|
||||
componentValues,
|
||||
}) ?? fallbackValue) as T;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,277 +1,285 @@
|
|||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
AnimationValueKind,
|
||||
} from "@/lib/animation/types";
|
||||
import {
|
||||
parseEffectParamPath,
|
||||
isEffectParamPath,
|
||||
} from "@/lib/animation/effect-param-channel";
|
||||
import {
|
||||
isGraphicParamPath,
|
||||
parseGraphicParamPath,
|
||||
} from "@/lib/animation/graphic-param-channel";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
|
||||
import { getGraphicDefinition } from "@/lib/graphics";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
type NumericSpec,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
|
||||
export interface AnimationPathDescriptor {
|
||||
valueKind: AnimationValueKind;
|
||||
defaultInterpolation: AnimationInterpolation;
|
||||
numericRange?: NumericSpec;
|
||||
getBaseValue(): AnimationValue | null;
|
||||
setBaseValue(value: AnimationValue): TimelineElement;
|
||||
}
|
||||
|
||||
export function getParamValueKind({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): AnimationValueKind {
|
||||
if (param.type === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return "color";
|
||||
}
|
||||
|
||||
return "discrete";
|
||||
}
|
||||
|
||||
export function getParamDefaultInterpolation({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): AnimationInterpolation {
|
||||
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
||||
}
|
||||
|
||||
function getParamNumericRange({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): NumericSpec | undefined {
|
||||
if (param.type !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
step: param.step,
|
||||
};
|
||||
}
|
||||
|
||||
function coerceParamValue({
|
||||
param,
|
||||
value,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
value: AnimationValue;
|
||||
}): number | string | boolean | null {
|
||||
if (param.type === "number") {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steppedValue = snapToStep({ value, step: param.step });
|
||||
const minValue = param.min;
|
||||
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
if (param.type === "boolean") {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return param.options.some((option) => option.value === value) ? value : null;
|
||||
}
|
||||
|
||||
function buildGraphicParamDescriptor({
|
||||
element,
|
||||
paramKey,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
paramKey: string;
|
||||
}): AnimationPathDescriptor | null {
|
||||
if (element.type !== "graphic") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = getGraphicDefinition({
|
||||
definitionId: element.definitionId,
|
||||
});
|
||||
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
||||
if (!param) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
valueKind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRange: getParamNumericRange({ param }),
|
||||
getBaseValue: () => element.params[param.key] ?? param.default,
|
||||
setBaseValue: (value) => {
|
||||
const coercedValue = coerceParamValue({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
params: {
|
||||
...element.params,
|
||||
[param.key]: coercedValue,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildEffectParamDescriptor({
|
||||
element,
|
||||
effectId,
|
||||
paramKey,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
}): AnimationPathDescriptor | null {
|
||||
if (!isVisualElement(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effect = element.effects?.find((candidate) => candidate.id === effectId);
|
||||
if (!effect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
registerDefaultEffects();
|
||||
const definition = effectsRegistry.get(effect.type);
|
||||
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
||||
if (!param) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
valueKind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRange: getParamNumericRange({ param }),
|
||||
getBaseValue: () => effect.params[param.key] ?? param.default,
|
||||
setBaseValue: (value) => {
|
||||
const coercedValue = coerceParamValue({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
effects:
|
||||
element.effects?.map((candidate) =>
|
||||
candidate.id !== effectId
|
||||
? candidate
|
||||
: {
|
||||
...candidate,
|
||||
params: {
|
||||
...candidate.params,
|
||||
[param.key]: coercedValue,
|
||||
},
|
||||
},
|
||||
) ?? element.effects,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isAnimationPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPath {
|
||||
return (
|
||||
isAnimationPropertyPath(propertyPath) ||
|
||||
isGraphicParamPath(propertyPath) ||
|
||||
isEffectParamPath(propertyPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAnimationTarget({
|
||||
element,
|
||||
path,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
path: AnimationPath;
|
||||
}): AnimationPathDescriptor | null {
|
||||
if (isAnimationPropertyPath(path)) {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({
|
||||
propertyPath: path,
|
||||
});
|
||||
if (!propertyDefinition.supportsElement({ element })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
valueKind: propertyDefinition.valueKind,
|
||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||
numericRange: propertyDefinition.numericRange,
|
||||
getBaseValue: () =>
|
||||
getElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath: path,
|
||||
}),
|
||||
setBaseValue: (value) => {
|
||||
const coercedValue = coerceAnimationValueForProperty({
|
||||
propertyPath: path,
|
||||
value,
|
||||
});
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath: path,
|
||||
value: coercedValue,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
|
||||
if (graphicParamTarget) {
|
||||
return buildGraphicParamDescriptor({
|
||||
element,
|
||||
paramKey: graphicParamTarget.paramKey,
|
||||
});
|
||||
}
|
||||
|
||||
const effectParamTarget = parseEffectParamPath({ propertyPath: path });
|
||||
if (effectParamTarget) {
|
||||
return buildEffectParamDescriptor({
|
||||
element,
|
||||
effectId: effectParamTarget.effectId,
|
||||
paramKey: effectParamTarget.paramKey,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
import type {
|
||||
AnimationBindingKind,
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
} from "@/lib/animation/types";
|
||||
import {
|
||||
parseEffectParamPath,
|
||||
isEffectParamPath,
|
||||
} from "@/lib/animation/effect-param-channel";
|
||||
import {
|
||||
isGraphicParamPath,
|
||||
parseGraphicParamPath,
|
||||
} from "@/lib/animation/graphic-param-channel";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
|
||||
import { getGraphicDefinition } from "@/lib/graphics";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
type NumericSpec,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
import { parseColorToLinearRgba } from "./binding-values";
|
||||
|
||||
export interface AnimationPathDescriptor {
|
||||
kind: AnimationBindingKind;
|
||||
defaultInterpolation: AnimationInterpolation;
|
||||
numericRanges?: Partial<Record<string, NumericSpec>>;
|
||||
coerceValue(value: AnimationValue): AnimationValue | null;
|
||||
getBaseValue(): AnimationValue | null;
|
||||
setBaseValue(value: AnimationValue): TimelineElement;
|
||||
}
|
||||
|
||||
export function getParamValueKind({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): AnimationBindingKind {
|
||||
if (param.type === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return "color";
|
||||
}
|
||||
|
||||
return "discrete";
|
||||
}
|
||||
|
||||
export function getParamDefaultInterpolation({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): AnimationInterpolation {
|
||||
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
||||
}
|
||||
|
||||
function getParamNumericRange({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): Partial<Record<string, NumericSpec>> | undefined {
|
||||
if (param.type !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
value: {
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
step: param.step,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function coerceAnimationValueForParam({
|
||||
param,
|
||||
value,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
value: AnimationValue;
|
||||
}): number | string | boolean | null {
|
||||
if (param.type === "number") {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steppedValue = snapToStep({ value, step: param.step });
|
||||
const minValue = param.min;
|
||||
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
if (param.type === "boolean") {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return param.options.some((option) => option.value === value) ? value : null;
|
||||
}
|
||||
|
||||
function buildGraphicParamDescriptor({
|
||||
element,
|
||||
paramKey,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
paramKey: string;
|
||||
}): AnimationPathDescriptor | null {
|
||||
if (element.type !== "graphic") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = getGraphicDefinition({
|
||||
definitionId: element.definitionId,
|
||||
});
|
||||
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
||||
if (!param) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRanges: getParamNumericRange({ param }),
|
||||
coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
|
||||
getBaseValue: () => element.params[param.key] ?? param.default,
|
||||
setBaseValue: (value) => {
|
||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
params: {
|
||||
...element.params,
|
||||
[param.key]: coercedValue,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildEffectParamDescriptor({
|
||||
element,
|
||||
effectId,
|
||||
paramKey,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
}): AnimationPathDescriptor | null {
|
||||
if (!isVisualElement(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effect = element.effects?.find((candidate) => candidate.id === effectId);
|
||||
if (!effect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
registerDefaultEffects();
|
||||
const definition = effectsRegistry.get(effect.type);
|
||||
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
||||
if (!param) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRanges: getParamNumericRange({ param }),
|
||||
coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
|
||||
getBaseValue: () => effect.params[param.key] ?? param.default,
|
||||
setBaseValue: (value) => {
|
||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
effects:
|
||||
element.effects?.map((candidate) =>
|
||||
candidate.id !== effectId
|
||||
? candidate
|
||||
: {
|
||||
...candidate,
|
||||
params: {
|
||||
...candidate.params,
|
||||
[param.key]: coercedValue,
|
||||
},
|
||||
},
|
||||
) ?? element.effects,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isAnimationPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPath {
|
||||
return (
|
||||
isAnimationPropertyPath(propertyPath) ||
|
||||
isGraphicParamPath(propertyPath) ||
|
||||
isEffectParamPath(propertyPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAnimationTarget({
|
||||
element,
|
||||
path,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
path: AnimationPath;
|
||||
}): AnimationPathDescriptor | null {
|
||||
if (isAnimationPropertyPath(path)) {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({
|
||||
propertyPath: path,
|
||||
});
|
||||
if (!propertyDefinition.supportsElement({ element })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: propertyDefinition.kind,
|
||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||
numericRanges: propertyDefinition.numericRanges,
|
||||
coerceValue: (value) =>
|
||||
coerceAnimationValueForProperty({
|
||||
propertyPath: path,
|
||||
value,
|
||||
}),
|
||||
getBaseValue: () =>
|
||||
getElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath: path,
|
||||
}),
|
||||
setBaseValue: (value) => {
|
||||
const coercedValue = propertyDefinition.coerceValue({ value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath: path,
|
||||
value: coercedValue,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
|
||||
if (graphicParamTarget) {
|
||||
return buildGraphicParamDescriptor({
|
||||
element,
|
||||
paramKey: graphicParamTarget.paramKey,
|
||||
});
|
||||
}
|
||||
|
||||
const effectParamTarget = parseEffectParamPath({ propertyPath: path });
|
||||
if (effectParamTarget) {
|
||||
return buildEffectParamDescriptor({
|
||||
element,
|
||||
effectId: effectParamTarget.effectId,
|
||||
paramKey: effectParamTarget.paramKey,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,119 +1,160 @@
|
|||
export const ANIMATION_PROPERTY_PATHS = [
|
||||
"transform.position",
|
||||
"transform.scaleX",
|
||||
"transform.scaleY",
|
||||
"transform.rotate",
|
||||
"opacity",
|
||||
"volume",
|
||||
"color",
|
||||
"background.color",
|
||||
"background.paddingX",
|
||||
"background.paddingY",
|
||||
"background.offsetX",
|
||||
"background.offsetY",
|
||||
"background.cornerRadius",
|
||||
] as const;
|
||||
|
||||
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
|
||||
export type GraphicParamPath = `params.${string}`;
|
||||
export type EffectParamPath = `effects.${string}.params.${string}`;
|
||||
export type AnimationPath =
|
||||
| AnimationPropertyPath
|
||||
| GraphicParamPath
|
||||
| EffectParamPath;
|
||||
|
||||
export const ANIMATION_PROPERTY_GROUPS = {
|
||||
"transform.scale": ["transform.scaleX", "transform.scaleY"],
|
||||
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
|
||||
|
||||
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
|
||||
|
||||
export type VectorValue = { x: number; y: number };
|
||||
|
||||
export type AnimationValueKind = "number" | "color" | "discrete" | "vector";
|
||||
export type DiscreteValue = boolean | string;
|
||||
export type AnimationValue = number | string | boolean | VectorValue;
|
||||
|
||||
export type ContinuousKeyframeInterpolation = "linear" | "hold";
|
||||
export type DiscreteKeyframeInterpolation = "hold";
|
||||
export type AnimationInterpolation =
|
||||
| ContinuousKeyframeInterpolation
|
||||
| DiscreteKeyframeInterpolation;
|
||||
|
||||
interface BaseAnimationKeyframe<
|
||||
TValue extends AnimationValue,
|
||||
TInterpolation extends AnimationInterpolation,
|
||||
> {
|
||||
id: string;
|
||||
time: number; // relative to element start time
|
||||
value: TValue;
|
||||
interpolation: TInterpolation;
|
||||
}
|
||||
|
||||
export interface NumberKeyframe
|
||||
extends BaseAnimationKeyframe<number, ContinuousKeyframeInterpolation> {}
|
||||
|
||||
export interface ColorKeyframe
|
||||
extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {}
|
||||
|
||||
export interface DiscreteKeyframe
|
||||
extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {}
|
||||
|
||||
export interface VectorKeyframe
|
||||
extends BaseAnimationKeyframe<VectorValue, ContinuousKeyframeInterpolation> {}
|
||||
|
||||
export type AnimationKeyframe =
|
||||
| NumberKeyframe
|
||||
| ColorKeyframe
|
||||
| DiscreteKeyframe
|
||||
| VectorKeyframe;
|
||||
|
||||
interface BaseAnimationChannel<
|
||||
TValueKind extends AnimationValueKind,
|
||||
TKeyframe extends AnimationKeyframe,
|
||||
> {
|
||||
valueKind: TValueKind;
|
||||
keyframes: TKeyframe[];
|
||||
}
|
||||
|
||||
export interface NumberAnimationChannel
|
||||
extends BaseAnimationChannel<"number", NumberKeyframe> {}
|
||||
|
||||
export interface ColorAnimationChannel
|
||||
extends BaseAnimationChannel<"color", ColorKeyframe> {}
|
||||
|
||||
export interface DiscreteAnimationChannel
|
||||
extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {}
|
||||
|
||||
export interface VectorAnimationChannel
|
||||
extends BaseAnimationChannel<"vector", VectorKeyframe> {}
|
||||
|
||||
export type AnimationChannel =
|
||||
| NumberAnimationChannel
|
||||
| ColorAnimationChannel
|
||||
| DiscreteAnimationChannel
|
||||
| VectorAnimationChannel;
|
||||
|
||||
export type ElementAnimationChannelMap = Record<
|
||||
string,
|
||||
AnimationChannel | undefined
|
||||
>;
|
||||
|
||||
export interface ElementAnimations {
|
||||
channels: ElementAnimationChannelMap;
|
||||
}
|
||||
|
||||
export interface ElementKeyframe {
|
||||
propertyPath: AnimationPath;
|
||||
id: string;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
}
|
||||
|
||||
export interface SelectedKeyframeRef {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
}
|
||||
export const ANIMATION_PROPERTY_PATHS = [
|
||||
"transform.position",
|
||||
"transform.scaleX",
|
||||
"transform.scaleY",
|
||||
"transform.rotate",
|
||||
"opacity",
|
||||
"volume",
|
||||
"color",
|
||||
"background.color",
|
||||
"background.paddingX",
|
||||
"background.paddingY",
|
||||
"background.offsetX",
|
||||
"background.offsetY",
|
||||
"background.cornerRadius",
|
||||
] as const;
|
||||
|
||||
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
|
||||
export type GraphicParamPath = `params.${string}`;
|
||||
export type EffectParamPath = `effects.${string}.params.${string}`;
|
||||
export type AnimationPath =
|
||||
| AnimationPropertyPath
|
||||
| GraphicParamPath
|
||||
| EffectParamPath;
|
||||
|
||||
export const ANIMATION_PROPERTY_GROUPS = {
|
||||
"transform.scale": ["transform.scaleX", "transform.scaleY"],
|
||||
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
|
||||
|
||||
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
|
||||
|
||||
export type VectorValue = { x: number; y: number };
|
||||
export type DiscreteValue = boolean | string;
|
||||
export type AnimationValue = number | string | boolean | VectorValue;
|
||||
|
||||
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
|
||||
export type DiscreteKeyframeInterpolation = "hold";
|
||||
export type AnimationInterpolation =
|
||||
| ContinuousKeyframeInterpolation
|
||||
| DiscreteKeyframeInterpolation;
|
||||
|
||||
export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
|
||||
export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
|
||||
export type ScalarSegmentType = "step" | "linear" | "bezier";
|
||||
export type TangentMode = "auto" | "aligned" | "broken" | "flat";
|
||||
export type ChannelExtrapolationMode = "hold" | "linear";
|
||||
|
||||
export interface CurveHandle {
|
||||
dt: number;
|
||||
dv: number;
|
||||
}
|
||||
|
||||
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
|
||||
id: string;
|
||||
time: number; // relative to element start time
|
||||
value: TValue;
|
||||
}
|
||||
|
||||
export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
|
||||
leftHandle?: CurveHandle;
|
||||
rightHandle?: CurveHandle;
|
||||
segmentToNext: ScalarSegmentType;
|
||||
tangentMode: TangentMode;
|
||||
}
|
||||
|
||||
export interface DiscreteAnimationKey
|
||||
extends BaseAnimationKeyframe<DiscreteValue> {}
|
||||
|
||||
export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
|
||||
|
||||
export interface ScalarAnimationChannel {
|
||||
kind: "scalar";
|
||||
keys: ScalarAnimationKey[];
|
||||
extrapolation?: {
|
||||
before: ChannelExtrapolationMode;
|
||||
after: ChannelExtrapolationMode;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DiscreteAnimationChannel {
|
||||
kind: "discrete";
|
||||
keys: DiscreteAnimationKey[];
|
||||
}
|
||||
|
||||
export type AnimationChannel =
|
||||
| ScalarAnimationChannel
|
||||
| DiscreteAnimationChannel;
|
||||
|
||||
export type ElementAnimationChannelMap = Record<
|
||||
string,
|
||||
AnimationChannel | undefined
|
||||
>;
|
||||
|
||||
export interface AnimationBindingComponent<TKey extends string = string> {
|
||||
key: TKey;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
interface BaseAnimationBinding<
|
||||
TKind extends AnimationBindingKind,
|
||||
TComponentKey extends string,
|
||||
> {
|
||||
path: AnimationPath;
|
||||
kind: TKind;
|
||||
components: AnimationBindingComponent<TComponentKey>[];
|
||||
}
|
||||
|
||||
export interface NumberAnimationBinding
|
||||
extends BaseAnimationBinding<"number", "value"> {}
|
||||
|
||||
export interface Vector2AnimationBinding
|
||||
extends BaseAnimationBinding<"vector2", "x" | "y"> {}
|
||||
|
||||
export interface ColorAnimationBinding
|
||||
extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> {
|
||||
colorSpace: "srgb-linear";
|
||||
}
|
||||
|
||||
export interface DiscreteAnimationBinding
|
||||
extends BaseAnimationBinding<"discrete", "value"> {}
|
||||
|
||||
export type AnimationBindingInstance =
|
||||
| NumberAnimationBinding
|
||||
| Vector2AnimationBinding
|
||||
| ColorAnimationBinding
|
||||
| DiscreteAnimationBinding;
|
||||
|
||||
export interface AnimationBindingByKind {
|
||||
number: NumberAnimationBinding;
|
||||
vector2: Vector2AnimationBinding;
|
||||
color: ColorAnimationBinding;
|
||||
discrete: DiscreteAnimationBinding;
|
||||
}
|
||||
|
||||
export type AnimationBindingOfKind<TKind extends AnimationBindingKind> =
|
||||
AnimationBindingByKind[TKind];
|
||||
|
||||
export type ElementAnimationBindingMap = Record<
|
||||
string,
|
||||
AnimationBindingInstance | undefined
|
||||
>;
|
||||
|
||||
export interface ElementAnimations {
|
||||
bindings: ElementAnimationBindingMap;
|
||||
channels: ElementAnimationChannelMap;
|
||||
}
|
||||
|
||||
export interface ElementKeyframe {
|
||||
propertyPath: AnimationPath;
|
||||
id: string;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
}
|
||||
|
||||
export interface SelectedKeyframeRef {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
getChannel,
|
||||
getChannelValueAtTime,
|
||||
hasKeyframesForPath,
|
||||
getKeyframeById,
|
||||
removeElementKeyframe,
|
||||
resolveAnimationPathValueAtTime,
|
||||
resolveAnimationTarget,
|
||||
} from "@/lib/animation";
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
|
|
@ -19,17 +20,6 @@ function sampleValueBeforeRemoval({
|
|||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
}): AnimationValue | null {
|
||||
const channel = getChannel({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
});
|
||||
const keyframe = channel?.keyframes.find(
|
||||
(candidate) => candidate.id === keyframeId,
|
||||
);
|
||||
if (!channel || !keyframe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = resolveAnimationTarget({ element, path: propertyPath });
|
||||
if (!target) {
|
||||
return null;
|
||||
|
|
@ -39,9 +29,19 @@ function sampleValueBeforeRemoval({
|
|||
return null;
|
||||
}
|
||||
|
||||
return getChannelValueAtTime({
|
||||
channel,
|
||||
time: keyframe.time,
|
||||
const keyframe = getKeyframeById({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
});
|
||||
if (!keyframe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
localTime: keyframe.time,
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
}
|
||||
|
|
@ -72,8 +72,10 @@ function removeKeyframeAndPersist({
|
|||
keyframeId,
|
||||
});
|
||||
|
||||
const isChannelNowEmpty =
|
||||
getChannel({ animations: nextAnimations, propertyPath }) === undefined;
|
||||
const isChannelNowEmpty = !hasKeyframesForPath({
|
||||
animations: nextAnimations,
|
||||
propertyPath,
|
||||
});
|
||||
const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
|
||||
|
||||
const baseElement = shouldPersistToBase
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
|
||||
import {
|
||||
buildEffectParamPath,
|
||||
resolveAnimationTarget,
|
||||
upsertPathKeyframe,
|
||||
} from "@/lib/animation";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import type { AnimationInterpolation } from "@/lib/animation/types";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
|
||||
export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
|
|
@ -12,8 +17,8 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
|||
private readonly effectId: string;
|
||||
private readonly paramKey: string;
|
||||
private readonly time: number;
|
||||
private readonly value: number;
|
||||
private readonly interpolation: "linear" | "hold" | undefined;
|
||||
private readonly value: number | string | boolean;
|
||||
private readonly interpolation: AnimationInterpolation | undefined;
|
||||
private readonly keyframeId: string | undefined;
|
||||
|
||||
constructor({
|
||||
|
|
@ -31,8 +36,8 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
|||
effectId: string;
|
||||
paramKey: string;
|
||||
time: number;
|
||||
value: number;
|
||||
interpolation?: "linear" | "hold";
|
||||
value: number | string | boolean;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}) {
|
||||
super();
|
||||
|
|
@ -57,14 +62,28 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
|||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
||||
const animations = upsertEffectParamKeyframe({
|
||||
animations: element.animations,
|
||||
const propertyPath = buildEffectParamPath({
|
||||
effectId: this.effectId,
|
||||
paramKey: this.paramKey,
|
||||
});
|
||||
const target = resolveAnimationTarget({
|
||||
element,
|
||||
path: propertyPath,
|
||||
});
|
||||
if (!target) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const animations = upsertPathKeyframe({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
time: boundedTime,
|
||||
value: this.value,
|
||||
interpolation: this.interpolation,
|
||||
keyframeId: this.keyframeId,
|
||||
kind: target.kind,
|
||||
defaultInterpolation: target.defaultInterpolation,
|
||||
coerceValue: target.coerceValue,
|
||||
});
|
||||
return { ...element, animations };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -73,9 +73,9 @@ export class UpsertKeyframeCommand extends Command {
|
|||
value: this.value,
|
||||
interpolation: this.interpolation,
|
||||
keyframeId: this.keyframeId,
|
||||
valueKind: target.valueKind,
|
||||
kind: target.kind,
|
||||
defaultInterpolation: target.defaultInterpolation,
|
||||
numericRange: target.numericRange,
|
||||
coerceValue: target.coerceValue,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export class ToggleSourceAudioSeparationCommand extends Command {
|
|||
return;
|
||||
}
|
||||
|
||||
if (canRecoverSourceAudio({ element: sourceElement })) {
|
||||
if (canRecoverSourceAudio(sourceElement)) {
|
||||
editor.timeline.updateTracks(
|
||||
updateSourceAudioEnabled({
|
||||
tracks: this.savedState,
|
||||
|
|
@ -63,7 +63,7 @@ export class ToggleSourceAudioSeparationCommand extends Command {
|
|||
.media
|
||||
.getAssets()
|
||||
.find((asset) => asset.id === sourceElement.mediaId);
|
||||
if (!canExtractSourceAudio({ element: sourceElement, mediaAsset })) {
|
||||
if (!canExtractSourceAudio(sourceElement, mediaAsset)) {
|
||||
return;
|
||||
}
|
||||
if (sourceElement.duration <= 0) {
|
||||
|
|
|
|||
|
|
@ -1,235 +1,237 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
|
||||
import { getMaskSnapGeometry } from "@/lib/masks/geometry";
|
||||
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
|
||||
|
||||
const bounds: ElementBounds = {
|
||||
cx: 200,
|
||||
cy: 150,
|
||||
width: 200,
|
||||
height: 100,
|
||||
rotation: 0,
|
||||
};
|
||||
|
||||
const canvasSize = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
};
|
||||
|
||||
const snapThreshold = {
|
||||
x: 8,
|
||||
y: 8,
|
||||
};
|
||||
|
||||
function buildSplitParams(
|
||||
overrides: Partial<SplitMaskParams> = {},
|
||||
): SplitMaskParams {
|
||||
return {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
rotation: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRectangleParams(
|
||||
overrides: Partial<RectangleMaskParams> = {},
|
||||
): RectangleMaskParams {
|
||||
return {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
width: 0.4,
|
||||
height: 0.2,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function sortSegment(
|
||||
segment: [{ x: number; y: number }, { x: number; y: number }],
|
||||
): [{ x: number; y: number }, { x: number; y: number }] {
|
||||
return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [
|
||||
{ x: number; y: number },
|
||||
{ x: number; y: number },
|
||||
];
|
||||
}
|
||||
|
||||
describe("mask geometry", () => {
|
||||
test("resolves split mask center from centerX and centerY", () => {
|
||||
expect(
|
||||
getMaskSnapGeometry({
|
||||
params: buildSplitParams({
|
||||
centerX: 0.25,
|
||||
centerY: -0.5,
|
||||
rotation: 45,
|
||||
}),
|
||||
bounds,
|
||||
}),
|
||||
).toEqual({
|
||||
position: { x: 50, y: -50 },
|
||||
size: { width: 0, height: 0 },
|
||||
rotation: 45,
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves box mask center and size from centerX and centerY", () => {
|
||||
expect(
|
||||
getMaskSnapGeometry({
|
||||
params: buildRectangleParams({
|
||||
centerX: -0.25,
|
||||
centerY: 0.5,
|
||||
width: 0.5,
|
||||
height: 0.6,
|
||||
rotation: 30,
|
||||
}),
|
||||
bounds,
|
||||
}),
|
||||
).toEqual({
|
||||
position: { x: -50, y: 50 },
|
||||
size: { width: 100, height: 60 },
|
||||
rotation: 30,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a vertical split stroke segment for rotation 0", () => {
|
||||
const segment = getSplitMaskStrokeSegment({
|
||||
resolvedParams: buildSplitParams(),
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
|
||||
expect(segment).not.toBeNull();
|
||||
if (!segment) {
|
||||
throw new Error("Expected split stroke segment for rotation 0");
|
||||
}
|
||||
expect(sortSegment(segment)).toEqual([
|
||||
{ x: bounds.width / 2, y: 0 },
|
||||
{ x: bounds.width / 2, y: bounds.height },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns a horizontal split stroke segment for rotation 90", () => {
|
||||
const segment = getSplitMaskStrokeSegment({
|
||||
resolvedParams: buildSplitParams({ rotation: 90 }),
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
|
||||
expect(segment).not.toBeNull();
|
||||
if (!segment) {
|
||||
throw new Error("Expected split stroke segment for rotation 90");
|
||||
}
|
||||
expect(sortSegment(segment)).toEqual([
|
||||
{ x: 0, y: bounds.height / 2 },
|
||||
{ x: bounds.width, y: bounds.height / 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mask snapping", () => {
|
||||
test("snaps split mask movement using the shared position pipeline", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "position",
|
||||
startParams: buildSplitParams({
|
||||
centerX: 0.03,
|
||||
centerY: -0.04,
|
||||
}),
|
||||
proposedParams: buildSplitParams({
|
||||
centerX: 0.03,
|
||||
centerY: -0.04,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.centerX).toBe(0);
|
||||
expect(result.params.centerY).toBe(0);
|
||||
expect(result.activeLines).toEqual([
|
||||
{ type: "vertical", position: 0 },
|
||||
{ type: "horizontal", position: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("snaps box mask movement against element center and edges", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "position",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
centerX: 0.29,
|
||||
centerY: 0.03,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.centerX).toBeCloseTo(0.3);
|
||||
expect(result.params.centerY).toBe(0);
|
||||
expect(result.activeLines).toEqual([
|
||||
{ type: "vertical", position: 100 },
|
||||
{ type: "horizontal", position: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("snaps mask rotation through the shared rotation path", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "rotation",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
rotation: 88,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.rotation).toBe(90);
|
||||
expect(result.activeLines).toEqual([]);
|
||||
});
|
||||
|
||||
test("snaps edge resize for box masks", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "right",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
width: 0.98,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.width).toBe(1);
|
||||
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
||||
});
|
||||
|
||||
test("snaps corner resize for box masks", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "bottom-right",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
width: 0.99,
|
||||
height: 0.495,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.width).toBe(1);
|
||||
expect(result.params.height).toBe(0.5);
|
||||
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
||||
});
|
||||
});
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
|
||||
import { getMaskSnapGeometry } from "@/lib/masks/geometry";
|
||||
import { snapMaskInteraction } from "@/lib/masks/snap";
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
|
||||
|
||||
const bounds: ElementBounds = {
|
||||
cx: 200,
|
||||
cy: 150,
|
||||
width: 200,
|
||||
height: 100,
|
||||
rotation: 0,
|
||||
};
|
||||
|
||||
const canvasSize = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
};
|
||||
|
||||
const snapThreshold = {
|
||||
x: 8,
|
||||
y: 8,
|
||||
};
|
||||
|
||||
function buildSplitParams(
|
||||
overrides: Partial<SplitMaskParams> = {},
|
||||
): SplitMaskParams {
|
||||
return {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
strokeAlign: "center",
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
rotation: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRectangleParams(
|
||||
overrides: Partial<RectangleMaskParams> = {},
|
||||
): RectangleMaskParams {
|
||||
return {
|
||||
feather: 0,
|
||||
inverted: false,
|
||||
strokeColor: "#ffffff",
|
||||
strokeWidth: 0,
|
||||
strokeAlign: "center",
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
width: 0.4,
|
||||
height: 0.2,
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function sortSegment(
|
||||
segment: [{ x: number; y: number }, { x: number; y: number }],
|
||||
): [{ x: number; y: number }, { x: number; y: number }] {
|
||||
return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [
|
||||
{ x: number; y: number },
|
||||
{ x: number; y: number },
|
||||
];
|
||||
}
|
||||
|
||||
describe("mask geometry", () => {
|
||||
test("resolves split mask center from centerX and centerY", () => {
|
||||
expect(
|
||||
getMaskSnapGeometry({
|
||||
params: buildSplitParams({
|
||||
centerX: 0.25,
|
||||
centerY: -0.5,
|
||||
rotation: 45,
|
||||
}),
|
||||
bounds,
|
||||
}),
|
||||
).toEqual({
|
||||
position: { x: 50, y: -50 },
|
||||
size: { width: 0, height: 0 },
|
||||
rotation: 45,
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves box mask center and size from centerX and centerY", () => {
|
||||
expect(
|
||||
getMaskSnapGeometry({
|
||||
params: buildRectangleParams({
|
||||
centerX: -0.25,
|
||||
centerY: 0.5,
|
||||
width: 0.5,
|
||||
height: 0.6,
|
||||
rotation: 30,
|
||||
}),
|
||||
bounds,
|
||||
}),
|
||||
).toEqual({
|
||||
position: { x: -50, y: 50 },
|
||||
size: { width: 100, height: 60 },
|
||||
rotation: 30,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a vertical split stroke segment for rotation 0", () => {
|
||||
const segment = getSplitMaskStrokeSegment({
|
||||
resolvedParams: buildSplitParams(),
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
|
||||
expect(segment).not.toBeNull();
|
||||
if (!segment) {
|
||||
throw new Error("Expected split stroke segment for rotation 0");
|
||||
}
|
||||
expect(sortSegment(segment)).toEqual([
|
||||
{ x: bounds.width / 2, y: 0 },
|
||||
{ x: bounds.width / 2, y: bounds.height },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns a horizontal split stroke segment for rotation 90", () => {
|
||||
const segment = getSplitMaskStrokeSegment({
|
||||
resolvedParams: buildSplitParams({ rotation: 90 }),
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
|
||||
expect(segment).not.toBeNull();
|
||||
if (!segment) {
|
||||
throw new Error("Expected split stroke segment for rotation 90");
|
||||
}
|
||||
expect(sortSegment(segment)).toEqual([
|
||||
{ x: 0, y: bounds.height / 2 },
|
||||
{ x: bounds.width, y: bounds.height / 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mask snapping", () => {
|
||||
test("snaps split mask movement using the shared position pipeline", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "position",
|
||||
startParams: buildSplitParams({
|
||||
centerX: 0.03,
|
||||
centerY: -0.04,
|
||||
}),
|
||||
proposedParams: buildSplitParams({
|
||||
centerX: 0.03,
|
||||
centerY: -0.04,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.centerX).toBe(0);
|
||||
expect(result.params.centerY).toBe(0);
|
||||
expect(result.activeLines).toEqual([
|
||||
{ type: "vertical", position: 0 },
|
||||
{ type: "horizontal", position: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("snaps box mask movement against element center and edges", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "position",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
centerX: 0.29,
|
||||
centerY: 0.03,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.centerX).toBeCloseTo(0.3);
|
||||
expect(result.params.centerY).toBe(0);
|
||||
expect(result.activeLines).toEqual([
|
||||
{ type: "vertical", position: 100 },
|
||||
{ type: "horizontal", position: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("snaps mask rotation through the shared rotation path", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "rotation",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
rotation: 88,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.rotation).toBe(90);
|
||||
expect(result.activeLines).toEqual([]);
|
||||
});
|
||||
|
||||
test("snaps edge resize for box masks", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "right",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
width: 0.98,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.width).toBe(1);
|
||||
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
||||
});
|
||||
|
||||
test("snaps corner resize for box masks", () => {
|
||||
const result = snapMaskInteraction({
|
||||
handleId: "bottom-right",
|
||||
startParams: buildRectangleParams(),
|
||||
proposedParams: buildRectangleParams({
|
||||
width: 0.99,
|
||||
height: 0.495,
|
||||
}),
|
||||
bounds,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
expect(result.params.width).toBe(1);
|
||||
expect(result.params.height).toBe(0.5);
|
||||
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { upsertElementKeyframe } from "@/lib/animation";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import type { AudioElement, VideoElement } from "@/lib/timeline";
|
||||
import {
|
||||
buildSeparatedAudioElement,
|
||||
|
|
@ -25,32 +27,7 @@ describe("audio separation", () => {
|
|||
volume: -6,
|
||||
muted: true,
|
||||
retime: { rate: 1.25, maintainPitch: true },
|
||||
animations: {
|
||||
channels: {
|
||||
volume: {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{
|
||||
id: "volume-keyframe",
|
||||
time: 2,
|
||||
value: -12,
|
||||
interpolation: "linear",
|
||||
},
|
||||
],
|
||||
},
|
||||
opacity: {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{
|
||||
id: "opacity-keyframe",
|
||||
time: 1,
|
||||
value: 0.5,
|
||||
interpolation: "linear",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
animations: buildAnimations(),
|
||||
});
|
||||
|
||||
const separatedAudioElement = buildSeparatedAudioElement({
|
||||
|
|
@ -71,21 +48,22 @@ describe("audio separation", () => {
|
|||
muted: true,
|
||||
retime: { rate: 1.25, maintainPitch: true },
|
||||
});
|
||||
expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([
|
||||
expect(Object.keys(separatedAudioElement.animations?.bindings ?? {})).toEqual([
|
||||
"volume",
|
||||
]);
|
||||
expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([
|
||||
"volume:value",
|
||||
]);
|
||||
expect(
|
||||
separatedAudioElement.animations?.channels.volume?.keyframes[0]?.id,
|
||||
separatedAudioElement.animations?.channels["volume:value"]?.keys[0]?.id,
|
||||
).not.toBe("volume-keyframe");
|
||||
});
|
||||
|
||||
test("skips source audio collection when the source clip is separated", () => {
|
||||
const mediaAsset = {
|
||||
const mediaAsset: MediaAsset = {
|
||||
id: "media-1",
|
||||
type: "video",
|
||||
name: "Clip",
|
||||
size: 1,
|
||||
lastModified: 1,
|
||||
file: new File(["video"], "clip.mp4", { type: "video/mp4" }),
|
||||
url: "blob:clip",
|
||||
hasAudio: true,
|
||||
|
|
@ -145,3 +123,23 @@ function buildVideoElement(
|
|||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnimations() {
|
||||
const withVolume = upsertElementKeyframe({
|
||||
animations: undefined,
|
||||
propertyPath: "volume",
|
||||
time: 2,
|
||||
value: -12,
|
||||
interpolation: "linear",
|
||||
keyframeId: "volume-keyframe",
|
||||
});
|
||||
|
||||
return upsertElementKeyframe({
|
||||
animations: withVolume,
|
||||
propertyPath: "opacity",
|
||||
time: 1,
|
||||
value: 0.5,
|
||||
interpolation: "linear",
|
||||
keyframeId: "opacity-keyframe",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { cloneAnimations, getChannel } from "@/lib/animation";
|
||||
import { cloneAnimations } from "@/lib/animation";
|
||||
import type { ElementAnimations } from "@/lib/animation/types";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
|
|
@ -25,13 +25,10 @@ export function isSourceAudioSeparated({
|
|||
return !isSourceAudioEnabled({ element });
|
||||
}
|
||||
|
||||
export function canExtractSourceAudio({
|
||||
element,
|
||||
mediaAsset,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
mediaAsset: MediaAsset | null | undefined;
|
||||
}): element is VideoElement {
|
||||
export function canExtractSourceAudio(
|
||||
element: TimelineElement,
|
||||
mediaAsset: MediaAsset | null | undefined,
|
||||
): element is VideoElement {
|
||||
return (
|
||||
element.type === "video" &&
|
||||
isSourceAudioEnabled({ element }) &&
|
||||
|
|
@ -40,25 +37,17 @@ export function canExtractSourceAudio({
|
|||
);
|
||||
}
|
||||
|
||||
export function canRecoverSourceAudio({
|
||||
element,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
}): element is VideoElement {
|
||||
export function canRecoverSourceAudio(
|
||||
element: TimelineElement,
|
||||
): element is VideoElement {
|
||||
return element.type === "video" && isSourceAudioSeparated({ element });
|
||||
}
|
||||
|
||||
export function canToggleSourceAudio({
|
||||
element,
|
||||
mediaAsset,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
mediaAsset: MediaAsset | null | undefined;
|
||||
}): element is VideoElement {
|
||||
return (
|
||||
canRecoverSourceAudio({ element }) ||
|
||||
canExtractSourceAudio({ element, mediaAsset })
|
||||
);
|
||||
export function canToggleSourceAudio(
|
||||
element: TimelineElement,
|
||||
mediaAsset: MediaAsset | null | undefined,
|
||||
): element is VideoElement {
|
||||
return canRecoverSourceAudio(element) || canExtractSourceAudio(element, mediaAsset);
|
||||
}
|
||||
|
||||
export function doesElementHaveEnabledAudio({
|
||||
|
|
@ -117,16 +106,27 @@ function cloneVolumeAnimations({
|
|||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
}): ElementAnimations | undefined {
|
||||
const volumeChannel = getChannel({ animations, propertyPath: "volume" });
|
||||
if (!volumeChannel) {
|
||||
const volumeBinding = animations?.bindings.volume;
|
||||
if (!volumeBinding) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const subsetChannels = Object.fromEntries(
|
||||
volumeBinding.components.flatMap((component) => {
|
||||
const channel = animations?.channels[component.channelId];
|
||||
return channel ? [[component.channelId, channel] as const] : [];
|
||||
}),
|
||||
);
|
||||
if (Object.keys(subsetChannels).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return cloneAnimations({
|
||||
animations: {
|
||||
channels: {
|
||||
volume: volumeChannel,
|
||||
bindings: {
|
||||
volume: volumeBinding,
|
||||
},
|
||||
channels: subsetChannels,
|
||||
},
|
||||
shouldRegenerateKeyframeIds: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { transformProjectV5ToV6 } from "../transformers/v5-to-v6";
|
||||
import { v5Project } from "./fixtures";
|
||||
|
||||
describe("V5 to V6 Migration", () => {
|
||||
test("converts number bookmarks to Bookmark objects", async () => {
|
||||
const result = transformProjectV5ToV6({
|
||||
project: v5Project as Parameters<
|
||||
typeof transformProjectV5ToV6
|
||||
>[0]["project"],
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(6);
|
||||
|
||||
const mainScene = (
|
||||
result.project.scenes as Array<{ bookmarks: unknown[] }>
|
||||
)[0];
|
||||
expect(mainScene.bookmarks).toEqual([
|
||||
{ time: 2.0 },
|
||||
{ time: 5.5 },
|
||||
{ time: 12.0 },
|
||||
]);
|
||||
|
||||
const introScene = (
|
||||
result.project.scenes as Array<{ bookmarks: unknown[] }>
|
||||
)[1];
|
||||
expect(introScene.bookmarks).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips projects that are already v6", () => {
|
||||
const result = transformProjectV5ToV6({
|
||||
project: {
|
||||
...v5Project,
|
||||
version: 6,
|
||||
scenes: [
|
||||
{
|
||||
...(v5Project as { scenes: unknown[] }).scenes[0],
|
||||
bookmarks: [{ time: 2 }, { time: 5 }],
|
||||
},
|
||||
],
|
||||
} as Parameters<typeof transformProjectV5ToV6>[0]["project"],
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("already v6");
|
||||
});
|
||||
|
||||
test("skips projects with no id", () => {
|
||||
const result = transformProjectV5ToV6({
|
||||
project: {
|
||||
version: 5,
|
||||
scenes: [],
|
||||
} as Parameters<typeof transformProjectV5ToV6>[0]["project"],
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("no project id");
|
||||
});
|
||||
|
||||
test("preserves existing Bookmark objects with note, color, duration", () => {
|
||||
const projectWithRichBookmarks = {
|
||||
...v5Project,
|
||||
version: 5,
|
||||
scenes: [
|
||||
{
|
||||
...(v5Project as { scenes: Array<Record<string, unknown>> })
|
||||
.scenes[0],
|
||||
bookmarks: [
|
||||
{ time: 1, note: "Intro", color: "#ef4444" },
|
||||
{ time: 5.5, duration: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformProjectV5ToV6({
|
||||
project: projectWithRichBookmarks as Parameters<
|
||||
typeof transformProjectV5ToV6
|
||||
>[0]["project"],
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
const mainScene = (
|
||||
result.project.scenes as Array<{ bookmarks: unknown[] }>
|
||||
)[0];
|
||||
expect(mainScene.bookmarks).toEqual([
|
||||
{ time: 1, note: "Intro", color: "#ef4444" },
|
||||
{ time: 5.5, duration: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { transformProjectV5ToV6 } from "../transformers/v5-to-v6";
|
||||
import { v5Project } from "./fixtures";
|
||||
|
||||
describe("V5 to V6 Migration", () => {
|
||||
test("converts number bookmarks to Bookmark objects", async () => {
|
||||
const result = transformProjectV5ToV6({
|
||||
project: v5Project as Parameters<
|
||||
typeof transformProjectV5ToV6
|
||||
>[0]["project"],
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(6);
|
||||
|
||||
const mainScene = (
|
||||
result.project.scenes as Array<{ bookmarks: unknown[] }>
|
||||
)[0];
|
||||
expect(mainScene.bookmarks).toEqual([
|
||||
{ time: 2.0 },
|
||||
{ time: 5.5 },
|
||||
{ time: 12.0 },
|
||||
]);
|
||||
|
||||
const introScene = (
|
||||
result.project.scenes as Array<{ bookmarks: unknown[] }>
|
||||
)[1];
|
||||
expect(introScene.bookmarks).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips projects that are already v6", () => {
|
||||
const firstScene = (v5Project as { scenes: Array<Record<string, unknown>> })
|
||||
.scenes[0];
|
||||
const result = transformProjectV5ToV6({
|
||||
project: {
|
||||
...v5Project,
|
||||
version: 6,
|
||||
scenes: [
|
||||
{
|
||||
...firstScene,
|
||||
bookmarks: [{ time: 2 }, { time: 5 }],
|
||||
},
|
||||
],
|
||||
} as Parameters<typeof transformProjectV5ToV6>[0]["project"],
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("already v6");
|
||||
});
|
||||
|
||||
test("skips projects with no id", () => {
|
||||
const result = transformProjectV5ToV6({
|
||||
project: {
|
||||
version: 5,
|
||||
scenes: [],
|
||||
} as Parameters<typeof transformProjectV5ToV6>[0]["project"],
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("no project id");
|
||||
});
|
||||
|
||||
test("preserves existing Bookmark objects with note, color, duration", () => {
|
||||
const projectWithRichBookmarks = {
|
||||
...v5Project,
|
||||
version: 5,
|
||||
scenes: [
|
||||
{
|
||||
...(v5Project as { scenes: Array<Record<string, unknown>> })
|
||||
.scenes[0],
|
||||
bookmarks: [
|
||||
{ time: 1, note: "Intro", color: "#ef4444" },
|
||||
{ time: 5.5, duration: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformProjectV5ToV6({
|
||||
project: projectWithRichBookmarks as Parameters<
|
||||
typeof transformProjectV5ToV6
|
||||
>[0]["project"],
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
const mainScene = (
|
||||
result.project.scenes as Array<{ bookmarks: unknown[] }>
|
||||
)[0];
|
||||
expect(mainScene.bookmarks).toEqual([
|
||||
{ time: 1, note: "Intro", color: "#ef4444" },
|
||||
{ time: 5.5, duration: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,10 +20,11 @@ import { V17toV18Migration } from "./v17-to-v18";
|
|||
import { V18toV19Migration } from "./v18-to-v19";
|
||||
import { V19toV20Migration } from "./v19-to-v20";
|
||||
import { V20toV21Migration } from "./v20-to-v21";
|
||||
import { V21toV22Migration } from "./v21-to-v22";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 21;
|
||||
export const CURRENT_PROJECT_VERSION = 22;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
|
|
@ -47,4 +48,5 @@ export const migrations = [
|
|||
new V18toV19Migration(),
|
||||
new V19toV20Migration(),
|
||||
new V20toV21Migration(),
|
||||
new V21toV22Migration(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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