feat: implement keyframe support for more properties at the core (no ui yet)

This commit is contained in:
Maze Winther 2026-03-02 15:08:21 +01:00
parent 887d1795dd
commit 81e7a7fb32
7 changed files with 108 additions and 19 deletions

View File

@ -0,0 +1,19 @@
import type {
AnimationPropertyPath,
ColorAnimationChannel,
ElementAnimations,
} from "@/types/animation";
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

@ -17,6 +17,7 @@ export {
export {
getElementLocalTime,
resolveColorAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
resolveVolumeAtTime,

View File

@ -277,7 +277,7 @@ export function getNumberChannelValueAtTime({
});
}
function getColorValueAtTime({
export function getColorValueAtTime({
channel,
time,
fallbackValue,

View File

@ -19,13 +19,13 @@ interface AnimationPropertyDefinition {
defaultInterpolation: AnimationInterpolation;
numericRange?: NumericRange;
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
getValue: ({ element }: { element: TimelineElement }) => number | null;
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
setValue: ({
element,
value,
}: {
element: TimelineElement;
value: number;
value: AnimationValue;
}) => TimelineElement;
}
@ -45,7 +45,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
...element,
transform: {
...element.transform,
position: { ...element.transform.position, x: value },
position: { ...element.transform.position, x: value as number },
},
}
: element,
@ -62,7 +62,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
...element,
transform: {
...element.transform,
position: { ...element.transform.position, y: value },
position: { ...element.transform.position, y: value as number },
},
}
: element,
@ -76,7 +76,10 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
isVisualElement(element) ? element.transform.scale : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? { ...element, transform: { ...element.transform, scale: value } }
? {
...element,
transform: { ...element.transform, scale: value as number },
}
: element,
},
"transform.rotate": {
@ -87,7 +90,10 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
isVisualElement(element) ? element.transform.rotate : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? { ...element, transform: { ...element.transform, rotate: value } }
? {
...element,
transform: { ...element.transform, rotate: value as number },
}
: element,
},
opacity: {
@ -98,7 +104,9 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
getValue: ({ element }) =>
isVisualElement(element) ? element.opacity : null,
setValue: ({ element, value }) =>
isVisualElement(element) ? { ...element, opacity: value } : element,
isVisualElement(element)
? { ...element, opacity: value as number }
: element,
},
volume: {
valueKind: "number",
@ -108,7 +116,33 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
getValue: ({ element }) =>
element.type === "audio" ? element.volume : null,
setValue: ({ element, value }) =>
element.type === "audio" ? { ...element, volume: value } : element,
element.type === "audio"
? { ...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,
},
};
@ -163,7 +197,7 @@ export function withElementBaseValueForProperty({
value: AnimationValue;
}): TimelineElement {
const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
if (coercedValue === null || typeof coercedValue !== "number") {
if (coercedValue === null) {
return element;
}
const definition = getAnimationPropertyDefinition({ propertyPath });

View File

@ -1,6 +1,7 @@
import type { ElementAnimations } from "@/types/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
import type { Transform } from "@/types/timeline";
import { getNumberChannelValueAtTime } from "./interpolation";
import { getColorValueAtTime, getNumberChannelValueAtTime } from "./interpolation";
import { getColorChannelForPath } from "./color-channel";
import { getNumberChannelForPath } from "./number-channel";
export function getElementLocalTime({
@ -91,6 +92,24 @@ export function resolveOpacityAtTime({
});
}
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,
});
}
export function resolveVolumeAtTime({
baseVolume,
animations,

View File

@ -17,6 +17,7 @@ import {
} from "@/lib/text/layout";
import {
getElementLocalTime,
resolveColorAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
} from "@/lib/animation";
@ -150,11 +151,24 @@ export class TextNode extends BaseNode<TextNodeParams> {
const lineCount = lines.length;
const block = measureTextBlock({ lineMetrics, lineHeightPx, fallbackFontSize: scaledFontSize });
const textColor = resolveColorAtTime({
baseColor: this.params.color,
animations: this.params.animations,
propertyPath: "color",
localTime,
});
const backgroundColor = resolveColorAtTime({
baseColor: this.params.background.color,
animations: this.params.animations,
propertyPath: "background.color",
localTime,
});
const drawContent = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
ctx.font = fontString;
ctx.textAlign = this.params.textAlign;
ctx.textBaseline = baseline;
ctx.fillStyle = this.params.color;
ctx.fillStyle = textColor;
if ("letterSpacing" in ctx) {
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
}
@ -165,7 +179,7 @@ export class TextNode extends BaseNode<TextNodeParams> {
this.params.background.color !== "transparent" &&
lineCount > 0
) {
const { color, cornerRadius = 0 } = this.params.background;
const { cornerRadius = 0 } = this.params.background;
const backgroundRect = getTextBackgroundRect({
textAlign: this.params.textAlign,
block,
@ -175,11 +189,11 @@ export class TextNode extends BaseNode<TextNodeParams> {
if (backgroundRect) {
const p = clamp({ value: cornerRadius, min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }) / 100;
const radius = Math.min(backgroundRect.width, backgroundRect.height) / 2 * p;
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(backgroundRect.left, backgroundRect.top, backgroundRect.width, backgroundRect.height, radius);
ctx.fill();
ctx.fillStyle = this.params.color;
ctx.fillStyle = backgroundColor;
ctx.beginPath();
ctx.roundRect(backgroundRect.left, backgroundRect.top, backgroundRect.width, backgroundRect.height, radius);
ctx.fill();
ctx.fillStyle = textColor;
}
}

View File

@ -5,6 +5,8 @@ export const ANIMATION_PROPERTY_PATHS = [
"transform.rotate",
"opacity",
"volume",
"color",
"background.color",
] as const;
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];