From 81e7a7fb32466633d38fa14bd5e14c2dca7bf658 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 2 Mar 2026 15:08:21 +0100 Subject: [PATCH] feat: implement keyframe support for more properties at the core (no ui yet) --- apps/web/src/lib/animation/color-channel.ts | 19 +++++++ apps/web/src/lib/animation/index.ts | 1 + apps/web/src/lib/animation/interpolation.ts | 2 +- .../src/lib/animation/property-registry.ts | 52 +++++++++++++++---- apps/web/src/lib/animation/resolve.ts | 23 +++++++- .../src/services/renderer/nodes/text-node.ts | 28 +++++++--- apps/web/src/types/animation.ts | 2 + 7 files changed, 108 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/lib/animation/color-channel.ts diff --git a/apps/web/src/lib/animation/color-channel.ts b/apps/web/src/lib/animation/color-channel.ts new file mode 100644 index 00000000..2a340f94 --- /dev/null +++ b/apps/web/src/lib/animation/color-channel.ts @@ -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; +} diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts index c41c1a7e..f6a0b974 100644 --- a/apps/web/src/lib/animation/index.ts +++ b/apps/web/src/lib/animation/index.ts @@ -17,6 +17,7 @@ export { export { getElementLocalTime, + resolveColorAtTime, resolveOpacityAtTime, resolveTransformAtTime, resolveVolumeAtTime, diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts index d3f68010..7a6fc6cf 100644 --- a/apps/web/src/lib/animation/interpolation.ts +++ b/apps/web/src/lib/animation/interpolation.ts @@ -277,7 +277,7 @@ export function getNumberChannelValueAtTime({ }); } -function getColorValueAtTime({ +export function getColorValueAtTime({ channel, time, fallbackValue, diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts index 44f5d638..69757d49 100644 --- a/apps/web/src/lib/animation/property-registry.ts +++ b/apps/web/src/lib/animation/property-registry.ts @@ -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 }); diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts index 58f292b1..450ae983 100644 --- a/apps/web/src/lib/animation/resolve.ts +++ b/apps/web/src/lib/animation/resolve.ts @@ -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, diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index ffffc895..e929c2bc 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -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 { 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 { 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 { 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; } } diff --git a/apps/web/src/types/animation.ts b/apps/web/src/types/animation.ts index 3985c5b2..547eec5d 100644 --- a/apps/web/src/types/animation.ts +++ b/apps/web/src/types/animation.ts @@ -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];