From 25317e84174b2fcdb8cb49ea9dd11a9b1e451a81 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Sun, 20 Apr 2025 15:59:52 +0200 Subject: [PATCH 1/6] a start --- package-lock.json | 22 +- packages/core/package.json | 2 - packages/core/src/types/Color.ts | 374 +++++++++++++++++++++---------- 3 files changed, 265 insertions(+), 133 deletions(-) diff --git a/package-lock.json b/package-lock.json index e81174a6e..af986ffb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3437,11 +3437,6 @@ "@types/chai": "*" } }, - "node_modules/@types/chroma-js": { - "version": "2.4.4", - "dev": true, - "license": "MIT" - }, "node_modules/@types/connect": { "version": "3.4.38", "dev": true, @@ -4971,10 +4966,6 @@ "node": ">=10" } }, - "node_modules/chroma-js": { - "version": "2.4.2", - "license": "(BSD-3-Clause AND Apache-2.0)" - }, "node_modules/chromium-bidi": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.5.tgz", @@ -17513,6 +17504,19 @@ "jsdom": "^22.1.0" } }, + "packages/core/node_modules/@types/chroma-js": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.4.tgz", + "integrity": "sha512-/DTccpHTaKomqussrn+ciEvfW4k6NAHzNzs/sts1TCqg333qNxOhy8TNIoQCmbGG3Tl8KdEhkGAssb1n3mTXiQ==", + "dev": true, + "license": "MIT" + }, + "packages/core/node_modules/chroma-js": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz", + "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, "packages/create": { "name": "@revideo/create", "version": "0.10.4", diff --git a/packages/core/package.json b/packages/core/package.json index cd895d507..7efcb3a0a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,11 +25,9 @@ "shaders" ], "dependencies": { - "chroma-js": "2.4.2", "mp4-wasm": "^1.0.6" }, "devDependencies": { - "@types/chroma-js": "2.4.4", "jsdom": "^22.1.0" } } diff --git a/packages/core/src/types/Color.ts b/packages/core/src/types/Color.ts index 71dfb19ab..8679e6ed1 100644 --- a/packages/core/src/types/Color.ts +++ b/packages/core/src/types/Color.ts @@ -1,155 +1,285 @@ -import type {ColorSpace, InterpolationMode} from 'chroma-js'; -import {Color as ChromaColor, mix} from 'chroma-js'; import type {Signal, SignalValue} from '../signals'; import {SignalContext} from '../signals'; import type {InterpolationFunction} from '../tweening'; +import {clamp} from '../tweening'; import type {Type, WebGLConvertible} from './Type'; export type SerializedColor = string; -export type PossibleColor = - | SerializedColor - | number - | ChromaColor - | {r: number; g: number; b: number; a: number}; - -export type ColorSignal = Signal; - -declare module 'chroma-js' { - interface Color extends Type, WebGLConvertible { - serialize(): string; - lerp( - to: ColorInterface | string, - value: number, - colorSpace?: ColorSpace, - ): ColorInterface; - } - type ColorInterface = ChromaColor; - type ColorSpace = - | 'rgb' - | 'hsl' - | 'hsv' - | 'lab' - | 'lch' - | 'lrgb' - | 'hcl' - | 'hsi' - | 'oklab' - | 'oklch'; - interface ColorStatic { - symbol: symbol; - lerp( - from: ColorInterface | string | null, - to: ColorInterface | string | null, - value: number, - colorSpace?: ColorSpace, - ): ColorInterface; - createLerp(colorSpace: ColorSpace): InterpolationFunction; - createSignal( - initial?: SignalValue, - interpolation?: InterpolationFunction, - ): ColorSignal; - } - interface ChromaStatic { - // eslint-disable-next-line @typescript-eslint/naming-convention - Color: ColorStatic & (new (color: PossibleColor) => ColorInterface); +// Basic color parsing regex +const HEX_REGEX = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i; +const RGB_REGEX = + /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/i; +const HSL_REGEX = + /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+%?)\s*,\s*([\d.]+%?)\s*(?:,\s*([\d.]+)\s*)?\)$/i; + +function parseHex(hex: string): [number, number, number, number] | null { + const result = HEX_REGEX.exec(hex); + if (!result) return null; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + const a = result[4] ? parseInt(result[4], 16) / 255 : 1; + return [r / 255, g / 255, b / 255, a]; +} + +function parseRgb(rgb: string): [number, number, number, number] | null { + const result = RGB_REGEX.exec(rgb); + if (!result) return null; + const r = parseInt(result[1], 10); + const g = parseInt(result[2], 10); + const b = parseInt(result[3], 10); + const a = result[4] ? parseFloat(result[4]) : 1; + return [r / 255, g / 255, b / 255, a]; +} + +function parseNumber(num: number): [number, number, number, number] { + const r = (num >> 16) & 255; + const g = (num >> 8) & 255; + const b = num & 255; + return [r / 255, g / 255, b / 255, 1]; +} + +// Simple HSL to RGB conversion (doesn't handle all edge cases perfectly) +function hslToRgb(h: number, s: number, l: number): [number, number, number] { + h /= 360; + s /= 100; + l /= 100; + let r, g, b; + + if (s === 0) { + r = g = b = l; // achromatic + } else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); } + + return [r, g, b]; } +function parseHsl(hsl: string): [number, number, number, number] | null { + const result = HSL_REGEX.exec(hsl); + if (!result) return null; + + const h = parseFloat(result[1]); + const s = parseFloat(result[2].replace('%', '')); + const l = parseFloat(result[3].replace('%', '')); + const a = result[4] ? parseFloat(result[4]) : 1; + + const [r, g, b] = hslToRgb(h, s, l); + return [r, g, b, a]; +} + +export interface ColorObject { + r: number; + g: number; + b: number; + a: number; +} + +export type PossibleColor = SerializedColor | number | Color | ColorObject; + +export type ColorSignal = Signal; + /** - * Represents a color. - * - * @remarks - * This is the same class as the one created by - * {@link https://gka.github.io/chroma.js/ | chroma.js}. Check out their - * documentation for more information on how to use it. + * Represents a color using RGBA values (0-1 range). */ -type ExtendedColor = ChromaColor; -// iife prevents tree shaking from stripping our methods. -const ExtendedColor: typeof ChromaColor = (() => { - ChromaColor.symbol = ChromaColor.prototype.symbol = Symbol.for( - '@revideo/core/types/Color', - ); - - ChromaColor.lerp = ChromaColor.prototype.lerp = ( - from: ChromaColor | string | null, - to: ChromaColor | string | null, - value: number, - colorSpace: InterpolationMode = 'lch', - ) => { - if (typeof from === 'string') { - from = new ChromaColor(from); +export class Color implements Type, WebGLConvertible { + public static symbol = Symbol.for('@revideo/core/types/Color'); + public readonly symbol = Color.symbol; + + public readonly r: number; + public readonly g: number; + public readonly b: number; + public readonly a: number; + + constructor(value?: PossibleColor) { + // Handle undefined/null case + if (value === undefined || value === null) { + this.r = 0; + this.g = 0; + this.b = 0; + this.a = 1; // Default alpha for undefined is 1 (fully opaque black) + return; } - if (typeof to === 'string') { - to = new ChromaColor(to); + + // Handle Color instance + if (value instanceof Color) { + this.r = value.r; + this.g = value.g; + this.b = value.b; + this.a = value.a; + return; } - const fromIsColor = from instanceof ChromaColor; - const toIsColor = to instanceof ChromaColor; + // Handle string parsing + if (typeof value === 'string') { + let parsed: [number, number, number, number] | null = null; + parsed = parseHex(value); + if (!parsed) { + parsed = parseRgb(value); + } + if (!parsed) { + parsed = parseHsl(value); + } - if (!fromIsColor) { - from = toIsColor - ? (to as ChromaColor).alpha(0) - : new ChromaColor('rgba(0, 0, 0, 0)'); + if (parsed) { + const [r, g, b, a] = parsed; + this.r = clamp(0, 1, r); + this.g = clamp(0, 1, g); + this.b = clamp(0, 1, b); + this.a = clamp(0, 1, a); + return; + } + + throw new Error(`Invalid color string value provided: ${value}`); + } + + // Handle number parsing + if (typeof value === 'number') { + const [r, g, b, a] = parseNumber(value); + this.r = clamp(0, 1, r); + this.g = clamp(0, 1, g); + this.b = clamp(0, 1, b); + this.a = clamp(0, 1, a); + return; } - if (!toIsColor) { - to = fromIsColor - ? (from as ChromaColor).alpha(0) - : new ChromaColor('rgba(0, 0, 0, 0)'); + + // Handle object parsing + if (typeof value === 'object') { + // Check for r, g, b properties to be reasonably sure it's a ColorObject + if ('r' in value && 'g' in value && 'b' in value) { + const obj = value as ColorObject; // Assume it matches the interface + this.r = clamp(0, 1, obj.r / 255); + this.g = clamp(0, 1, obj.g / 255); + this.b = clamp(0, 1, obj.b / 255); + this.a = clamp(0, 1, obj.a ?? 1); + return; + } } - return mix(from as ChromaColor, to as ChromaColor, value, colorSpace); - }; + throw new Error(`Invalid color value provided: ${value}`); + } + + /** + * Linearly interpolates between two colors in RGB space. + */ + static lerp( + from: PossibleColor | null, + to: PossibleColor | null, + value: number, + ): Color { + const fromColor = + from instanceof Color ? from : new Color(from ?? undefined); + const toColor = to instanceof Color ? to : new Color(to ?? undefined); + + const r = fromColor.r + (toColor.r - fromColor.r) * value; + const g = fromColor.g + (toColor.g - fromColor.g) * value; + const b = fromColor.b + (toColor.b - fromColor.b) * value; + const a = fromColor.a + (toColor.a - fromColor.a) * value; + + return new Color({r: r * 255, g: g * 255, b: b * 255, a}); + } - ChromaColor.createLerp = ChromaColor.prototype.createLerp = - (colorSpace: InterpolationMode) => - ( - from: ChromaColor | string | null, - to: ChromaColor | string | null, - value: number, - ) => - ChromaColor.lerp(from, to, value, colorSpace); + /** + * Creates an interpolation function for colors (always uses linear RGB). + */ + static createLerp(): InterpolationFunction { + return Color.lerp; + } - ChromaColor.createSignal = ( + /** + * Creates a signal for the Color type. + */ + static createSignal( initial?: SignalValue, - interpolation: InterpolationFunction = ChromaColor.lerp, - ): ColorSignal => { - return new SignalContext( + interpolation: InterpolationFunction = Color.lerp, + ): ColorSignal { + return new SignalContext( initial, interpolation, undefined, - value => new ChromaColor(value), + value => (value instanceof Color ? value : new Color(value)), ).toSignal(); - }; + } - ChromaColor.prototype.toSymbol = () => { - return ChromaColor.symbol; - }; + toSymbol(): symbol { + return this.symbol; + } + + /** + * Returns the color components as a [r, g, b, a] array (0-1 range). + */ + gl(): [number, number, number, number] { + return [this.r, this.g, this.b, this.a]; + } - ChromaColor.prototype.toUniform = function ( - this: ChromaColor, - gl: WebGL2RenderingContext, - location: WebGLUniformLocation, - ): void { + toUniform(gl: WebGL2RenderingContext, location: WebGLUniformLocation): void { gl.uniform4fv(location, this.gl()); - }; + } - ChromaColor.prototype.serialize = function ( - this: ChromaColor, - ): SerializedColor { - return this.css(); - }; + /** + * Serializes the color to an `rgba()` CSS string. + */ + serialize(): SerializedColor { + const r = Math.round(this.r * 255); + const g = Math.round(this.g * 255); + const b = Math.round(this.b * 255); + return `rgba(${r}, ${g}, ${b}, ${this.a.toFixed(3)})`; + } - ChromaColor.prototype.lerp = function ( - this: ChromaColor, - to: ChromaColor, - value: number, - colorSpace?: ColorSpace, - ) { - return ChromaColor.lerp(this, to, value, colorSpace); - }; + /** + * Serializes the color to an `rgb()` CSS string (omitting alpha). + */ + css(): SerializedColor { + const r = Math.round(this.r * 255); + const g = Math.round(this.g * 255); + const b = Math.round(this.b * 255); + return `rgb(${r},${g},${b})`; + } + + /** + * Returns the alpha value of the color (0-1 range). + */ + alpha(): number { + return this.a; + } - return ChromaColor; -})(); + /** + * Serializes the color to an RRGGBBAA hex string. + */ + hex(): string { + const r = Math.round(this.r * 255) + .toString(16) + .padStart(2, '0'); + const g = Math.round(this.g * 255) + .toString(16) + .padStart(2, '0'); + const b = Math.round(this.b * 255) + .toString(16) + .padStart(2, '0'); + const a = Math.round(this.a * 255) + .toString(16) + .padStart(2, '0'); + return `#${r}${g}${b}${a}`; + } -export {ExtendedColor as Color}; + /** + * Linearly interpolates from this color to another in RGB space. + */ + lerp(to: PossibleColor, value: number): Color { + return Color.lerp(this, to, value); + } +} From b8b037479e43d1bf781065fed90652a629110808 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Sun, 20 Apr 2025 16:17:18 +0200 Subject: [PATCH 2/6] fix lerp --- package-lock.json | 33 ++++++----- packages/core/package.json | 2 + packages/core/src/types/Color.test.ts | 33 +++++++++++ packages/core/src/types/Color.ts | 80 +++++++++++++++++++-------- 4 files changed, 111 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index af986ffb6..27c798e2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3462,6 +3462,13 @@ "@types/node": "*" } }, + "node_modules/@types/culori": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@types/culori/-/culori-2.1.1.tgz", + "integrity": "sha512-NzLYD0vNHLxTdPp8+RlvGbR2NfOZkwxcYGFwxNtm+WH2NuUNV8785zv1h0sulFQ5aFQ9n/jNDUuJeo3Bh7+oFA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.5", "dev": true, @@ -6417,6 +6424,15 @@ "dev": true, "license": "MIT" }, + "node_modules/culori": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/culori/-/culori-4.0.1.tgz", + "integrity": "sha512-LSnjA6HuIUOlkfKVbzi2OlToZE8OjFi667JWN9qNymXVXzGDmvuP60SSgC+e92sd7B7158f7Fy3Mb6rXS5EDPw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/dargs": { "version": "7.0.0", "dev": true, @@ -17496,27 +17512,14 @@ "version": "0.10.4", "license": "MIT", "dependencies": { - "chroma-js": "2.4.2", + "culori": "^4.0.1", "mp4-wasm": "^1.0.6" }, "devDependencies": { - "@types/chroma-js": "2.4.4", + "@types/culori": "^2.1.1", "jsdom": "^22.1.0" } }, - "packages/core/node_modules/@types/chroma-js": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.4.tgz", - "integrity": "sha512-/DTccpHTaKomqussrn+ciEvfW4k6NAHzNzs/sts1TCqg333qNxOhy8TNIoQCmbGG3Tl8KdEhkGAssb1n3mTXiQ==", - "dev": true, - "license": "MIT" - }, - "packages/core/node_modules/chroma-js": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz", - "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==", - "license": "(BSD-3-Clause AND Apache-2.0)" - }, "packages/create": { "name": "@revideo/create", "version": "0.10.4", diff --git a/packages/core/package.json b/packages/core/package.json index 7efcb3a0a..65dabd64b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,9 +25,11 @@ "shaders" ], "dependencies": { + "culori": "^4.0.1", "mp4-wasm": "^1.0.6" }, "devDependencies": { + "@types/culori": "^2.1.1", "jsdom": "^22.1.0" } } diff --git a/packages/core/src/types/Color.test.ts b/packages/core/src/types/Color.test.ts index 3ea742b26..2fef33974 100644 --- a/packages/core/src/types/Color.test.ts +++ b/packages/core/src/types/Color.test.ts @@ -21,3 +21,36 @@ describe('Color.lerp', () => { ); }); }); + +describe('Color Constructor', () => { + test('parses 4-digit hex codes correctly', () => { + // Opaque + expect(new Color('#f00').serialize()).toBe('rgba(255, 0, 0, 1.000)'); + expect(new Color('#0f0').serialize()).toBe('rgba(0, 255, 0, 1.000)'); + // Transparent + expect(new Color('#00f8').serialize()).toBe('rgba(0, 0, 255, 0.533)'); // 8/15 = 0.5333... + expect(new Color('#fff0').serialize()).toBe('rgba(255, 255, 255, 0.000)'); + }); + + test('parses 8-digit hex codes correctly', () => { + // Opaque + expect(new Color('#ff0000ff').serialize()).toBe('rgba(255, 0, 0, 1.000)'); + // Transparent + expect(new Color('#00ff0080').serialize()).toBe('rgba(0, 255, 0, 0.502)'); // 128/255 = 0.5019... + expect(new Color('#0000ff00').serialize()).toBe('rgba(0, 0, 255, 0.000)'); + }); + + test('parses CSS color names correctly', () => { + expect(new Color('red').serialize()).toBe('rgba(255, 0, 0, 1.000)'); + expect(new Color('lime').serialize()).toBe('rgba(0, 255, 0, 1.000)'); // CSS 'green' is #008000 + expect(new Color('blue').serialize()).toBe('rgba(0, 0, 255, 1.000)'); + expect(new Color('lightgray').serialize()).toBe( + 'rgba(211, 211, 211, 1.000)', + ); + expect(new Color('lightgrey').serialize()).toBe( + 'rgba(211, 211, 211, 1.000)', + ); // Alias + expect(new Color('transparent').serialize()).toBe('rgba(0, 0, 0, 0.000)'); + expect(new Color('TRANSPARENT').serialize()).toBe('rgba(0, 0, 0, 0.000)'); // Case-insensitive + }); +}); diff --git a/packages/core/src/types/Color.ts b/packages/core/src/types/Color.ts index 8679e6ed1..599a5d7cd 100644 --- a/packages/core/src/types/Color.ts +++ b/packages/core/src/types/Color.ts @@ -1,3 +1,4 @@ +import {converter, formatHex8, interpolate} from 'culori'; import type {Signal, SignalValue} from '../signals'; import {SignalContext} from '../signals'; import type {InterpolationFunction} from '../tweening'; @@ -127,7 +128,9 @@ export class Color implements Type, WebGLConvertible { // Handle string parsing if (typeof value === 'string') { let parsed: [number, number, number, number] | null = null; + parsed = parseHex(value); + if (!parsed) { parsed = parseRgb(value); } @@ -174,7 +177,7 @@ export class Color implements Type, WebGLConvertible { } /** - * Linearly interpolates between two colors in RGB space. + * Interpolates between two colors using LCH color space. */ static lerp( from: PossibleColor | null, @@ -185,16 +188,52 @@ export class Color implements Type, WebGLConvertible { from instanceof Color ? from : new Color(from ?? undefined); const toColor = to instanceof Color ? to : new Color(to ?? undefined); - const r = fromColor.r + (toColor.r - fromColor.r) * value; - const g = fromColor.g + (toColor.g - fromColor.g) * value; - const b = fromColor.b + (toColor.b - fromColor.b) * value; + // Define culori colors in {r, g, b} format (0-1 range) + const startColorCulori = { + mode: 'rgb', + r: fromColor.r, + g: fromColor.g, + b: fromColor.b, + } as const; + const endColorCulori = { + mode: 'rgb', + r: toColor.r, + g: toColor.g, + b: toColor.b, + } as const; + + // Create LCH interpolator using culori + const interpolator = interpolate( + [startColorCulori, endColorCulori], + 'lch', // Interpolation space (culori might handle hue automatically) + ); + + // Get the interpolated color in LCH mode from culori + const interpolatedLch = interpolator(value); + + // Convert the interpolated LCH color back to RGB + const rgbConverter = converter('rgb'); + const interpolatedRgb = rgbConverter(interpolatedLch); + + // Interpolate alpha linearly const a = fromColor.a + (toColor.a - fromColor.a) * value; - return new Color({r: r * 255, g: g * 255, b: b * 255, a}); + // Create a new Color instance, clamping RGB values from culori + // Check if interpolatedRgb is valid before accessing properties + const finalR = interpolatedRgb ? clamp(0, 1, interpolatedRgb.r) : 0; + const finalG = interpolatedRgb ? clamp(0, 1, interpolatedRgb.g) : 0; + const finalB = interpolatedRgb ? clamp(0, 1, interpolatedRgb.b) : 0; + + return new Color({ + r: finalR * 255, + g: finalG * 255, + b: finalB * 255, + a: clamp(0, 1, a), // Also clamp alpha just in case + }); } /** - * Creates an interpolation function for colors (always uses linear RGB). + * Creates an interpolation function for colors (uses LCH space via culori). */ static createLerp(): InterpolationFunction { return Color.lerp; @@ -237,7 +276,9 @@ export class Color implements Type, WebGLConvertible { const r = Math.round(this.r * 255); const g = Math.round(this.g * 255); const b = Math.round(this.b * 255); - return `rgba(${r}, ${g}, ${b}, ${this.a.toFixed(3)})`; + // Use toFixed(3) for alpha like before, clamp to avoid -0 + const alphaStr = clamp(0, 1, this.a).toFixed(3); + return `rgba(${r}, ${g}, ${b}, ${alphaStr})`; } /** @@ -258,26 +299,21 @@ export class Color implements Type, WebGLConvertible { } /** - * Serializes the color to an RRGGBBAA hex string. + * Serializes the color to an RRGGBBAA hex string using culori. */ hex(): string { - const r = Math.round(this.r * 255) - .toString(16) - .padStart(2, '0'); - const g = Math.round(this.g * 255) - .toString(16) - .padStart(2, '0'); - const b = Math.round(this.b * 255) - .toString(16) - .padStart(2, '0'); - const a = Math.round(this.a * 255) - .toString(16) - .padStart(2, '0'); - return `#${r}${g}${b}${a}`; + // Use culori's formatter for consistency + return formatHex8({ + mode: 'rgb', + r: this.r, + g: this.g, + b: this.b, + alpha: this.a, + }); } /** - * Linearly interpolates from this color to another in RGB space. + * Linearly interpolates from this color to another using LCH space. */ lerp(to: PossibleColor, value: number): Color { return Color.lerp(this, to, value); From 6ee69a8345d7fb417b1b4bb6d33affe94d16ef4a Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Sun, 20 Apr 2025 16:26:27 +0200 Subject: [PATCH 3/6] finish --- packages/core/src/types/Color.test.ts | 18 +++-- packages/core/src/types/Color.ts | 104 ++++---------------------- 2 files changed, 29 insertions(+), 93 deletions(-) diff --git a/packages/core/src/types/Color.test.ts b/packages/core/src/types/Color.test.ts index 2fef33974..c19a4d617 100644 --- a/packages/core/src/types/Color.test.ts +++ b/packages/core/src/types/Color.test.ts @@ -24,12 +24,12 @@ describe('Color.lerp', () => { describe('Color Constructor', () => { test('parses 4-digit hex codes correctly', () => { - // Opaque + // Opaque (#rgb -> #rrggbb) expect(new Color('#f00').serialize()).toBe('rgba(255, 0, 0, 1.000)'); expect(new Color('#0f0').serialize()).toBe('rgba(0, 255, 0, 1.000)'); - // Transparent - expect(new Color('#00f8').serialize()).toBe('rgba(0, 0, 255, 0.533)'); // 8/15 = 0.5333... - expect(new Color('#fff0').serialize()).toBe('rgba(255, 255, 255, 0.000)'); + // Transparent (#rgba -> #rrggbbaa) + expect(new Color('#00f8').serialize()).toBe('rgba(0, 0, 255, 0.533)'); // 88/255 = 0.5333... + expect(new Color('#fff0').serialize()).toBe('rgba(255, 255, 255, 0.000)'); // 00/255 = 0 }); test('parses 8-digit hex codes correctly', () => { @@ -51,6 +51,14 @@ describe('Color Constructor', () => { 'rgba(211, 211, 211, 1.000)', ); // Alias expect(new Color('transparent').serialize()).toBe('rgba(0, 0, 0, 0.000)'); - expect(new Color('TRANSPARENT').serialize()).toBe('rgba(0, 0, 0, 0.000)'); // Case-insensitive + }); + + test('handles invalid string input', () => { + expect(() => new Color('invalid-color-string')).toThrow( + 'Invalid color string value provided: invalid-color-string', + ); + expect(() => new Color('#gggggg')).toThrow( + 'Invalid color string value provided: #gggggg', + ); }); }); diff --git a/packages/core/src/types/Color.ts b/packages/core/src/types/Color.ts index 599a5d7cd..ef45d7241 100644 --- a/packages/core/src/types/Color.ts +++ b/packages/core/src/types/Color.ts @@ -1,4 +1,4 @@ -import {converter, formatHex8, interpolate} from 'culori'; +import {converter, formatHex8, interpolate, parse} from 'culori'; import type {Signal, SignalValue} from '../signals'; import {SignalContext} from '../signals'; import type {InterpolationFunction} from '../tweening'; @@ -7,33 +7,6 @@ import type {Type, WebGLConvertible} from './Type'; export type SerializedColor = string; -// Basic color parsing regex -const HEX_REGEX = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i; -const RGB_REGEX = - /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/i; -const HSL_REGEX = - /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+%?)\s*,\s*([\d.]+%?)\s*(?:,\s*([\d.]+)\s*)?\)$/i; - -function parseHex(hex: string): [number, number, number, number] | null { - const result = HEX_REGEX.exec(hex); - if (!result) return null; - const r = parseInt(result[1], 16); - const g = parseInt(result[2], 16); - const b = parseInt(result[3], 16); - const a = result[4] ? parseInt(result[4], 16) / 255 : 1; - return [r / 255, g / 255, b / 255, a]; -} - -function parseRgb(rgb: string): [number, number, number, number] | null { - const result = RGB_REGEX.exec(rgb); - if (!result) return null; - const r = parseInt(result[1], 10); - const g = parseInt(result[2], 10); - const b = parseInt(result[3], 10); - const a = result[4] ? parseFloat(result[4]) : 1; - return [r / 255, g / 255, b / 255, a]; -} - function parseNumber(num: number): [number, number, number, number] { const r = (num >> 16) & 255; const g = (num >> 8) & 255; @@ -41,48 +14,6 @@ function parseNumber(num: number): [number, number, number, number] { return [r / 255, g / 255, b / 255, 1]; } -// Simple HSL to RGB conversion (doesn't handle all edge cases perfectly) -function hslToRgb(h: number, s: number, l: number): [number, number, number] { - h /= 360; - s /= 100; - l /= 100; - let r, g, b; - - if (s === 0) { - r = g = b = l; // achromatic - } else { - const hue2rgb = (p: number, q: number, t: number) => { - if (t < 0) t += 1; - if (t > 1) t -= 1; - if (t < 1 / 6) return p + (q - p) * 6 * t; - if (t < 1 / 2) return q; - if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; - return p; - }; - - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; - const p = 2 * l - q; - r = hue2rgb(p, q, h + 1 / 3); - g = hue2rgb(p, q, h); - b = hue2rgb(p, q, h - 1 / 3); - } - - return [r, g, b]; -} - -function parseHsl(hsl: string): [number, number, number, number] | null { - const result = HSL_REGEX.exec(hsl); - if (!result) return null; - - const h = parseFloat(result[1]); - const s = parseFloat(result[2].replace('%', '')); - const l = parseFloat(result[3].replace('%', '')); - const a = result[4] ? parseFloat(result[4]) : 1; - - const [r, g, b] = hslToRgb(h, s, l); - return [r, g, b, a]; -} - export interface ColorObject { r: number; g: number; @@ -125,29 +56,26 @@ export class Color implements Type, WebGLConvertible { return; } - // Handle string parsing + // Handle string parsing using culori if (typeof value === 'string') { - let parsed: [number, number, number, number] | null = null; - - parsed = parseHex(value); - - if (!parsed) { - parsed = parseRgb(value); - } - if (!parsed) { - parsed = parseHsl(value); + const parsedColor = parse(value); + if (!parsedColor) { + throw new Error(`Invalid color string value provided: ${value}`); } - if (parsed) { - const [r, g, b, a] = parsed; - this.r = clamp(0, 1, r); - this.g = clamp(0, 1, g); - this.b = clamp(0, 1, b); - this.a = clamp(0, 1, a); + // Convert parsed color to RGB if it's not already + const rgbColor = + parsedColor.mode === 'rgb' + ? parsedColor + : converter('rgb')(parsedColor); + + if (rgbColor) { + this.r = clamp(0, 1, rgbColor.r); + this.g = clamp(0, 1, rgbColor.g); + this.b = clamp(0, 1, rgbColor.b); + this.a = clamp(0, 1, rgbColor.alpha ?? 1); return; } - - throw new Error(`Invalid color string value provided: ${value}`); } // Handle number parsing From e21fe760e9d99478d6d5cc54d17a44a6832375cd Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Sun, 20 Apr 2025 16:34:20 +0200 Subject: [PATCH 4/6] chore: accessibility modifiers --- packages/core/src/types/Color.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/core/src/types/Color.ts b/packages/core/src/types/Color.ts index ef45d7241..fddc4264e 100644 --- a/packages/core/src/types/Color.ts +++ b/packages/core/src/types/Color.ts @@ -37,7 +37,7 @@ export class Color implements Type, WebGLConvertible { public readonly b: number; public readonly a: number; - constructor(value?: PossibleColor) { + public constructor(value?: PossibleColor) { // Handle undefined/null case if (value === undefined || value === null) { this.r = 0; @@ -107,7 +107,7 @@ export class Color implements Type, WebGLConvertible { /** * Interpolates between two colors using LCH color space. */ - static lerp( + public static lerp( from: PossibleColor | null, to: PossibleColor | null, value: number, @@ -163,14 +163,14 @@ export class Color implements Type, WebGLConvertible { /** * Creates an interpolation function for colors (uses LCH space via culori). */ - static createLerp(): InterpolationFunction { + public static createLerp(): InterpolationFunction { return Color.lerp; } /** * Creates a signal for the Color type. */ - static createSignal( + public static createSignal( initial?: SignalValue, interpolation: InterpolationFunction = Color.lerp, ): ColorSignal { @@ -182,25 +182,28 @@ export class Color implements Type, WebGLConvertible { ).toSignal(); } - toSymbol(): symbol { + public toSymbol(): symbol { return this.symbol; } /** * Returns the color components as a [r, g, b, a] array (0-1 range). */ - gl(): [number, number, number, number] { + private gl(): [number, number, number, number] { return [this.r, this.g, this.b, this.a]; } - toUniform(gl: WebGL2RenderingContext, location: WebGLUniformLocation): void { + public toUniform( + gl: WebGL2RenderingContext, + location: WebGLUniformLocation, + ): void { gl.uniform4fv(location, this.gl()); } /** * Serializes the color to an `rgba()` CSS string. */ - serialize(): SerializedColor { + public serialize(): SerializedColor { const r = Math.round(this.r * 255); const g = Math.round(this.g * 255); const b = Math.round(this.b * 255); @@ -212,7 +215,7 @@ export class Color implements Type, WebGLConvertible { /** * Serializes the color to an `rgb()` CSS string (omitting alpha). */ - css(): SerializedColor { + public css(): SerializedColor { const r = Math.round(this.r * 255); const g = Math.round(this.g * 255); const b = Math.round(this.b * 255); @@ -222,14 +225,14 @@ export class Color implements Type, WebGLConvertible { /** * Returns the alpha value of the color (0-1 range). */ - alpha(): number { + public alpha(): number { return this.a; } /** * Serializes the color to an RRGGBBAA hex string using culori. */ - hex(): string { + public hex(): string { // Use culori's formatter for consistency return formatHex8({ mode: 'rgb', @@ -243,7 +246,7 @@ export class Color implements Type, WebGLConvertible { /** * Linearly interpolates from this color to another using LCH space. */ - lerp(to: PossibleColor, value: number): Color { + public lerp(to: PossibleColor, value: number): Color { return Color.lerp(this, to, value); } } From b19ba5dd2237d4d7d6aa8fd7035632752a221489 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Sun, 20 Apr 2025 16:50:43 +0200 Subject: [PATCH 5/6] fix: build examples --- packages/core/src/types/Color.test.ts | 37 +++++++++++++++++++++ packages/core/src/types/Color.ts | 48 ++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/packages/core/src/types/Color.test.ts b/packages/core/src/types/Color.test.ts index c19a4d617..f10504260 100644 --- a/packages/core/src/types/Color.test.ts +++ b/packages/core/src/types/Color.test.ts @@ -22,6 +22,43 @@ describe('Color.lerp', () => { }); }); +describe('Color.createLerp', () => { + test('creates an interpolation function with default LCH mode', () => { + const lerpFn = Color.createLerp(); + expect( + lerpFn( + new Color('rgb(0, 0, 0)'), + new Color('rgb(255, 255, 255)'), + 0.5, + ).css(), + ).toEqual('rgb(119,119,119)'); + }); + + test('creates an interpolation function with a specified mode (e.g., lab)', () => { + const lerpFn = Color.createLerp('lab'); + const expected = Color.lerp( + 'rgb(0, 0, 0)', + 'rgb(255, 255, 255)', + 0.5, + 'lab', + ); + + expect( + lerpFn( + new Color('rgb(0, 0, 0)'), + new Color('rgb(255, 255, 255)'), + 0.5, + ).css(), + ).toEqual(expected.css()); + + // Example with a different color pair to ensure mode is used + const blueToYellowLerp = Color.createLerp('lab'); + expect( + blueToYellowLerp(new Color('blue'), new Color('yellow'), 0.5).css(), + ).toMatchInlineSnapshot(`"rgb(193,137,172)"`); + }); +}); + describe('Color Constructor', () => { test('parses 4-digit hex codes correctly', () => { // Opaque (#rgb -> #rrggbb) diff --git a/packages/core/src/types/Color.ts b/packages/core/src/types/Color.ts index fddc4264e..deaffbbeb 100644 --- a/packages/core/src/types/Color.ts +++ b/packages/core/src/types/Color.ts @@ -23,6 +23,37 @@ export interface ColorObject { export type PossibleColor = SerializedColor | number | Color | ColorObject; +type CuloriInterpolatorMode = + | 'a98' + | 'cubehelix' + | 'dlab' + | 'dlch' + | 'hsi' + | 'hsl' + | 'hsv' + | 'hwb' + | 'jab' + | 'jch' + | 'lab' + | 'lab65' + | 'lch' + | 'lch65' + | 'lchuv' + | 'lrgb' + | 'luv' + | 'okhsl' + | 'okhsv' + | 'oklab' + | 'oklch' + | 'p3' + | 'prophoto' + | 'rec2020' + | 'rgb' + | 'xyz' + | 'xyz50' + | 'xyz65' + | 'yiq'; + export type ColorSignal = Signal; /** @@ -111,6 +142,7 @@ export class Color implements Type, WebGLConvertible { from: PossibleColor | null, to: PossibleColor | null, value: number, + mode: CuloriInterpolatorMode = 'lch', // Default to LCH ): Color { const fromColor = from instanceof Color ? from : new Color(from ?? undefined); @@ -131,9 +163,10 @@ export class Color implements Type, WebGLConvertible { } as const; // Create LCH interpolator using culori + const interpolationMode = mode ?? 'lch'; // Ensure mode is not undefined const interpolator = interpolate( [startColorCulori, endColorCulori], - 'lch', // Interpolation space (culori might handle hue automatically) + interpolationMode as any, // Use specified interpolation space (casting to bypass TS error) ); // Get the interpolated color in LCH mode from culori @@ -163,8 +196,9 @@ export class Color implements Type, WebGLConvertible { /** * Creates an interpolation function for colors (uses LCH space via culori). */ - public static createLerp(): InterpolationFunction { - return Color.lerp; + public static createLerp(mode: CuloriInterpolatorMode = 'lch') { + return (from: PossibleColor, to: PossibleColor, value: number) => + Color.lerp(from, to, value, mode); } /** @@ -246,7 +280,11 @@ export class Color implements Type, WebGLConvertible { /** * Linearly interpolates from this color to another using LCH space. */ - public lerp(to: PossibleColor, value: number): Color { - return Color.lerp(this, to, value); + public lerp( + to: PossibleColor, + value: number, + mode: CuloriInterpolatorMode = 'lch', + ): Color { + return Color.lerp(this, to, value, mode); } } From 9c3c9dcc45fe375a69f20d753ab2794178798758 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Sun, 20 Apr 2025 16:55:30 +0200 Subject: [PATCH 6/6] fix: type issue --- packages/core/src/types/Color.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/types/Color.ts b/packages/core/src/types/Color.ts index deaffbbeb..d6bbb67c2 100644 --- a/packages/core/src/types/Color.ts +++ b/packages/core/src/types/Color.ts @@ -196,8 +196,10 @@ export class Color implements Type, WebGLConvertible { /** * Creates an interpolation function for colors (uses LCH space via culori). */ - public static createLerp(mode: CuloriInterpolatorMode = 'lch') { - return (from: PossibleColor, to: PossibleColor, value: number) => + public static createLerp( + mode: CuloriInterpolatorMode = 'lch', + ): InterpolationFunction { + return (from: PossibleColor, to: PossibleColor, value) => Color.lerp(from, to, value, mode); }