refactor: rewrite animation system to use bindings and scalar channels

This commit is contained in:
Maze 2026-04-05 13:15:58 +02:00
parent 69da5bee0c
commit 1bd9fac203
35 changed files with 5242 additions and 3235 deletions

View File

@ -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,
};
}

View File

@ -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 })

View File

@ -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>

View File

@ -294,12 +294,7 @@ export function useEditorActions() {
null
);
})();
if (
!canToggleSourceAudio({
element: selectedElement.element,
mediaAsset,
})
) {
if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
return;
}

View File

@ -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

View File

@ -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");
});
});

View File

@ -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,
});
});
});

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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,
});
}

View File

@ -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;
}

View File

@ -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";

View File

@ -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,

View File

@ -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

View File

@ -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;
}

View File

@ -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 });
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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

View File

@ -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 };
},

View File

@ -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,
}),
};
},

View File

@ -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) {

View File

@ -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 }]);
});
});

View File

@ -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",
});
}

View File

@ -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,
});

View File

@ -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");
});
});

View File

@ -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 },
]);
});
});

View File

@ -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(),
];

View File

@ -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}`;
}

View File

@ -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 });
}
}