summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/animation/binding-values.ts
diff options
context:
space:
mode:
authorMaze <mazewinther@gmail.com>2026-04-05 13:15:58 +0200
committerMaze <mazewinther@gmail.com>2026-04-05 13:15:58 +0200
commit1bd9fac203f09736c1225bbbe19e5d7cc31c0b93 (patch)
treed5c65add056209043e627a53e47e6ebbf74e64c6 /apps/web/src/lib/animation/binding-values.ts
parent69da5bee0c50f331ec59a510d0cf0d45779bab31 (diff)
downloadopencut-1bd9fac203f09736c1225bbbe19e5d7cc31c0b93.tar.gz
refactor: rewrite animation system to use bindings and scalar channels
Diffstat (limited to 'apps/web/src/lib/animation/binding-values.ts')
-rw-r--r--apps/web/src/lib/animation/binding-values.ts335
1 files changed, 335 insertions, 0 deletions
diff --git a/apps/web/src/lib/animation/binding-values.ts b/apps/web/src/lib/animation/binding-values.ts
new file mode 100644
index 00000000..6f7ce995
--- /dev/null
+++ b/apps/web/src/lib/animation/binding-values.ts
@@ -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;
+}