diff --git a/package-lock.json b/package-lock.json index e81174a6e..27c798e2f 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, @@ -3467,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, @@ -4971,10 +4973,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", @@ -6426,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, @@ -17505,11 +17512,11 @@ "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" } }, diff --git a/packages/core/package.json b/packages/core/package.json index cd895d507..65dabd64b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,11 +25,11 @@ "shaders" ], "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" } } diff --git a/packages/core/src/types/Color.test.ts b/packages/core/src/types/Color.test.ts index 3ea742b26..f10504260 100644 --- a/packages/core/src/types/Color.test.ts +++ b/packages/core/src/types/Color.test.ts @@ -21,3 +21,81 @@ 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) + 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 (#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', () => { + // 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)'); + }); + + 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 71dfb19ab..d6bbb67c2 100644 --- a/packages/core/src/types/Color.ts +++ b/packages/core/src/types/Color.ts @@ -1,155 +1,292 @@ -import type {ColorSpace, InterpolationMode} from 'chroma-js'; -import {Color as ChromaColor, mix} from 'chroma-js'; +import {converter, formatHex8, interpolate, parse} from 'culori'; 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); - } +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]; } +export interface ColorObject { + r: number; + g: number; + b: number; + a: number; +} + +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; + /** - * 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; + + public 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 using culori + if (typeof value === 'string') { + const parsedColor = parse(value); + if (!parsedColor) { + throw new Error(`Invalid color string value provided: ${value}`); + } + + // Convert parsed color to RGB if it's not already + const rgbColor = + parsedColor.mode === 'rgb' + ? parsedColor + : converter('rgb')(parsedColor); - if (!fromIsColor) { - from = toIsColor - ? (to as ChromaColor).alpha(0) - : new ChromaColor('rgba(0, 0, 0, 0)'); + 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; + } } - if (!toIsColor) { - to = fromIsColor - ? (from as ChromaColor).alpha(0) - : new ChromaColor('rgba(0, 0, 0, 0)'); + + // 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; + } + + // 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}`); + } + + /** + * Interpolates between two colors using LCH color space. + */ + public static lerp( + 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); + const toColor = to instanceof Color ? to : new Color(to ?? undefined); + + // 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 interpolationMode = mode ?? 'lch'; // Ensure mode is not undefined + const interpolator = interpolate( + [startColorCulori, endColorCulori], + interpolationMode as any, // Use specified interpolation space (casting to bypass TS error) + ); - ChromaColor.createLerp = ChromaColor.prototype.createLerp = - (colorSpace: InterpolationMode) => - ( - from: ChromaColor | string | null, - to: ChromaColor | string | null, - value: number, - ) => - ChromaColor.lerp(from, to, value, colorSpace); + // Get the interpolated color in LCH mode from culori + const interpolatedLch = interpolator(value); - ChromaColor.createSignal = ( + // 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; + + // 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 (uses LCH space via culori). + */ + public static createLerp( + mode: CuloriInterpolatorMode = 'lch', + ): InterpolationFunction { + return (from: PossibleColor, to: PossibleColor, value) => + Color.lerp(from, to, value, mode); + } + + /** + * Creates a signal for the Color type. + */ + public 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; - }; + public toSymbol(): symbol { + return this.symbol; + } + + /** + * Returns the color components as a [r, g, b, a] array (0-1 range). + */ + private gl(): [number, number, number, number] { + return [this.r, this.g, this.b, this.a]; + } - ChromaColor.prototype.toUniform = function ( - this: ChromaColor, + public 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. + */ + public serialize(): SerializedColor { + const r = Math.round(this.r * 255); + const g = Math.round(this.g * 255); + const b = Math.round(this.b * 255); + // 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})`; + } - 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). + */ + public 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). + */ + public alpha(): number { + return this.a; + } - return ChromaColor; -})(); + /** + * Serializes the color to an RRGGBBAA hex string using culori. + */ + public hex(): string { + // Use culori's formatter for consistency + return formatHex8({ + mode: 'rgb', + r: this.r, + g: this.g, + b: this.b, + alpha: this.a, + }); + } -export {ExtendedColor as Color}; + /** + * Linearly interpolates from this color to another using LCH space. + */ + public lerp( + to: PossibleColor, + value: number, + mode: CuloriInterpolatorMode = 'lch', + ): Color { + return Color.lerp(this, to, value, mode); + } +}