feat: add channel tools
This commit is contained in:
+8
-2
@@ -52,6 +52,13 @@
|
|||||||
- gamma-png — value
|
- gamma-png — value
|
||||||
- tint-png — color, strength
|
- tint-png — color, strength
|
||||||
|
|
||||||
|
### Разложение каналов
|
||||||
|
|
||||||
|
- png-to-hsl / png-to-hsv / png-to-hsi — component (h/s/l и т.п.), display (gray | space-as-rgb)
|
||||||
|
- png-to-cmyk — component (c/m/y/k), display
|
||||||
|
- png-to-ycbcr — component (y/cb/cr), display
|
||||||
|
- png-to-lab — component (l/a/b), display
|
||||||
|
|
||||||
### Геометрия
|
### Геометрия
|
||||||
|
|
||||||
- resize-png — width (0=авто), height (0=авто), keepAspect
|
- resize-png — width (0=авто), height (0=авто), keepAspect
|
||||||
@@ -110,9 +117,8 @@
|
|||||||
- mix-colors — colors[], веса?
|
- mix-colors — colors[], веса?
|
||||||
- average-color — colors[]; blend-two — a, b, steps; step-between — a, b, steps (три частных случая одного движка)
|
- average-color — colors[]; blend-two — a, b, steps; step-between — a, b, steps (три частных случая одного движка)
|
||||||
|
|
||||||
### Разложение каналов
|
### Разложение каналов — остаток
|
||||||
|
|
||||||
- png-to-hsl / hsv / hsi / cmyk / ycbcr / lab — channel (какой компонент показать), режим отображения (серый/окрашенный)
|
|
||||||
- separate-colors — minShare слоя (MEDIUM, мультифайловый вывод → пока идея)
|
- separate-colors — minShare слоя (MEDIUM, мультифайловый вывод → пока идея)
|
||||||
|
|
||||||
### Маски по свойствам пикселей
|
### Маски по свойствам пикселей
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { hexToRgb } from './palette';
|
||||||
|
import { renderSpace, SPACES } from './channels';
|
||||||
|
import { makeImage } from './test-helpers';
|
||||||
|
|
||||||
|
describe('преобразования пространств', () => {
|
||||||
|
it('hsl красного: h=0, s=1, l=0.5', () => {
|
||||||
|
const [h, s, l] = SPACES.hsl.convert(hexToRgb('#ff0000'));
|
||||||
|
expect(h).toBeCloseTo(0);
|
||||||
|
expect(s).toBeCloseTo(1);
|
||||||
|
expect(l).toBeCloseTo(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hsv белого: v=1, чёрного: v=0', () => {
|
||||||
|
expect(SPACES.hsv.convert({ r: 255, g: 255, b: 255 })[2]).toBeCloseTo(1);
|
||||||
|
expect(SPACES.hsv.convert({ r: 0, g: 0, b: 0 })[2]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hsi серого: s=0', () => {
|
||||||
|
const [, s] = SPACES.hsi.convert({ r: 128, g: 128, b: 128 });
|
||||||
|
expect(s).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cmyk: белый — 0,0,0,0; чёрный — 0,0,0,1', () => {
|
||||||
|
const w = SPACES.cmyk.convert({ r: 255, g: 255, b: 255 });
|
||||||
|
const b = SPACES.cmyk.convert({ r: 0, g: 0, b: 0 });
|
||||||
|
expect(w.every((v) => Math.abs(v) < 1e-9)).toBe(true);
|
||||||
|
expect(b[3]).toBeCloseTo(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ycbcr белого: y≈1, cb≈cr≈0.5', () => {
|
||||||
|
const [y, cb, cr] = SPACES.ycbcr.convert({ r: 255, g: 255, b: 255 });
|
||||||
|
expect(y).toBeCloseTo(1);
|
||||||
|
expect(cb).toBeCloseTo(0.5, 2);
|
||||||
|
expect(cr).toBeCloseTo(0.5, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lab белого: l≈1, a≈b≈0.5 (центр)', () => {
|
||||||
|
const [l, a, bb] = SPACES.lab.convert({ r: 255, g: 255, b: 255 });
|
||||||
|
expect(l).toBeCloseTo(1, 2);
|
||||||
|
expect(a).toBeCloseTo(0.5, 2);
|
||||||
|
expect(bb).toBeCloseTo(0.5, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderSpace', () => {
|
||||||
|
const red = makeImage(1, 1, [[255, 0, 0, 255]]);
|
||||||
|
|
||||||
|
it('gray-режим: компонент y (жёлтый) чистого красного = белый', () => {
|
||||||
|
const out = renderSpace(red, 'cmyk', 'y', 'gray');
|
||||||
|
expect([...out.data]).toEqual([255, 255, 255, 255]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('color-режим hsl красного: каналы = тон/насыщенность/светлота', () => {
|
||||||
|
const out = renderSpace(red, 'hsl', 's', 'color');
|
||||||
|
expect(out.data[0]).toBe(0);
|
||||||
|
expect(out.data[1]).toBe(255);
|
||||||
|
expect(out.data[2]).toBeGreaterThanOrEqual(127);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('неизвестное пространство или компонент дают прозрачную заглушку', () => {
|
||||||
|
const junk = makeImage(1, 1, [[10, 20, 30, 255]]);
|
||||||
|
expect(renderSpace(junk, 'cmyk' as never, 'zz' as never, 'gray').data[3]).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { PixelImage } from './types';
|
||||||
|
import { createPixelImage } from './types';
|
||||||
|
import { rgbToHsl } from './palette';
|
||||||
|
import type { Rgb } from './palette';
|
||||||
|
|
||||||
|
/** Все компоненты нормализованы в 0..1 в порядке объявления. */
|
||||||
|
export type SpaceComponents = number[];
|
||||||
|
|
||||||
|
export type SpaceId = 'hsl' | 'hsv' | 'hsi' | 'cmyk' | 'ycbcr' | 'lab';
|
||||||
|
|
||||||
|
function hueOf({ r, g, b }: Rgb): number {
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
if (max === min) return 0;
|
||||||
|
const d = max - min;
|
||||||
|
let h: number;
|
||||||
|
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
|
||||||
|
else if (max === g) h = ((b - r) / d + 2) * 60;
|
||||||
|
else h = ((r - g) / d + 4) * 60;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHsv({ r, g, b }: Rgb): [number, number, number] {
|
||||||
|
r /= 255;
|
||||||
|
g /= 255;
|
||||||
|
b /= 255;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
return [hueOf({ r, g, b }), max === 0 ? 0 : (max - min) / max, max];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHsi({ r, g, b }: Rgb): [number, number, number] {
|
||||||
|
r /= 255;
|
||||||
|
g /= 255;
|
||||||
|
b /= 255;
|
||||||
|
const sum = r + g + b;
|
||||||
|
const intensity = sum / 3;
|
||||||
|
if (sum === 0) return [0, 0, 0];
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const saturation = 1 - min / intensity;
|
||||||
|
return [hueOf({ r, g, b }), saturation, intensity];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToCmyk({ r, g, b }: Rgb): [number, number, number, number] {
|
||||||
|
const rn = r / 255;
|
||||||
|
const gn = g / 255;
|
||||||
|
const bn = b / 255;
|
||||||
|
const k = 1 - Math.max(rn, gn, bn);
|
||||||
|
if (k === 1) return [0, 0, 0, 1];
|
||||||
|
return [(1 - rn - k) / (1 - k), (1 - gn - k) / (1 - k), (1 - bn - k) / (1 - k), k];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToYcbcr({ r, g, b }: Rgb): [number, number, number] {
|
||||||
|
const y = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
const cb = 128 - 0.168736 * r - 0.331264 * g + 0.5 * b;
|
||||||
|
const cr = 128 + 0.5 * r - 0.418688 * g - 0.081312 * b;
|
||||||
|
return [y / 255, cb / 255, cr / 255];
|
||||||
|
}
|
||||||
|
|
||||||
|
function srgbTransfer(v: number): number {
|
||||||
|
return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToLab({ r, g, b }: Rgb): [number, number, number] {
|
||||||
|
const lin = [r, g, b].map((v) => srgbTransfer(v / 255));
|
||||||
|
const x = (lin[0] * 0.4124 + lin[1] * 0.3576 + lin[2] * 0.1805) / 0.95047;
|
||||||
|
const y = lin[0] * 0.2126 + lin[1] * 0.7152 + lin[2] * 0.0722;
|
||||||
|
const z = (lin[0] * 0.0193 + lin[1] * 0.1192 + lin[2] * 0.9505) / 1.08883;
|
||||||
|
const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
|
||||||
|
const fx = f(x);
|
||||||
|
const fy = f(y);
|
||||||
|
const fz = f(z);
|
||||||
|
// L нормирован 0..1; a/b центрированы на 0.5 с размахом ±0.5
|
||||||
|
return [(116 * fy - 16) / 100, (500 * (fx - fy)) / 250 + 0.5, (200 * (fy - fz)) / 250 + 0.5];
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpaceDef = { components: string[]; convert: (rgb: Rgb) => SpaceComponents };
|
||||||
|
|
||||||
|
export const SPACES: Record<SpaceId, SpaceDef> = {
|
||||||
|
hsl: {
|
||||||
|
components: ['h', 's', 'l'],
|
||||||
|
convert: ({ r, g, b }) => {
|
||||||
|
const { h, s, l } = rgbToHsl({ r, g, b });
|
||||||
|
return [h / 360, s, l];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hsv: { components: ['h', 's', 'v'], convert: rgbToHsv },
|
||||||
|
hsi: { components: ['h', 's', 'i'], convert: rgbToHsi },
|
||||||
|
cmyk: { components: ['c', 'm', 'y', 'k'], convert: rgbToCmyk },
|
||||||
|
ycbcr: { components: ['y', 'cb', 'cr'], convert: rgbToYcbcr },
|
||||||
|
lab: { components: ['l', 'a', 'b'], convert: rgbToLab }
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChannelDisplay = 'gray' | 'color';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Визуализация выбранного пространства: каждый компонент пространства
|
||||||
|
* попадает в свой канал результата (mode 'color') или выбранный компонент
|
||||||
|
* рисуется градациями серого (mode 'gray').
|
||||||
|
*/
|
||||||
|
export function renderSpace(
|
||||||
|
img: PixelImage,
|
||||||
|
space: SpaceId,
|
||||||
|
component: string,
|
||||||
|
display: ChannelDisplay
|
||||||
|
): PixelImage {
|
||||||
|
const def = SPACES[space];
|
||||||
|
if (!def) return createPixelImage(img.width, img.height);
|
||||||
|
const idx = def.components.indexOf(component);
|
||||||
|
if (idx < 0) return createPixelImage(img.width, img.height);
|
||||||
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
for (let i = 0; i < img.data.length; i += 4) {
|
||||||
|
const comps = def.convert({ r: img.data[i], g: img.data[i + 1], b: img.data[i + 2] });
|
||||||
|
const di = i;
|
||||||
|
out.data[di + 3] = img.data[i + 3];
|
||||||
|
if (display === 'gray') {
|
||||||
|
const v = comps[idx] * 255;
|
||||||
|
out.data[di] = v;
|
||||||
|
out.data[di + 1] = v;
|
||||||
|
out.data[di + 2] = v;
|
||||||
|
} else {
|
||||||
|
out.data[di] = comps[0] * 255;
|
||||||
|
out.data[di + 1] = (comps[1] ?? 0) * 255;
|
||||||
|
out.data[di + 2] = (comps[2] ?? 0) * 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -547,6 +547,72 @@ export const ru: Dict = {
|
|||||||
grayscaleNo: 'Нет — найдены цветные пиксели.'
|
grayscaleNo: 'Нет — найдены цветные пиксели.'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'png-to-hsl': {
|
||||||
|
title: 'Разложить PNG в HSL',
|
||||||
|
description: 'Раскладывает изображение на компоненты Тон, Насыщенность и Светлота.',
|
||||||
|
params: { component: 'Компонент', display: 'Режим показа' },
|
||||||
|
options: {
|
||||||
|
component: { h: 'Тон (H)', s: 'Насыщенность (S)', l: 'Светлота (L)' },
|
||||||
|
display: { gray: 'Градациями серого', color: 'Пространство как RGB' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'png-to-hsv': {
|
||||||
|
title: 'Разложить PNG в HSV',
|
||||||
|
description: 'Раскладывает изображение на Тон, Насыщенность и Яркость (Value).',
|
||||||
|
params: { component: 'Компонент', display: 'Режим показа' },
|
||||||
|
options: {
|
||||||
|
component: { h: 'Тон (H)', s: 'Насыщенность (S)', v: 'Яркость (V)' },
|
||||||
|
display: { gray: 'Градациями серого', color: 'Пространство как RGB' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'png-to-hsi': {
|
||||||
|
title: 'Разложить PNG в HSI',
|
||||||
|
description: 'Раскладывает изображение на Тон, Насыщенность и Интенсивность.',
|
||||||
|
params: { component: 'Компонент', display: 'Режим показа' },
|
||||||
|
options: {
|
||||||
|
component: { h: 'Тон (H)', s: 'Насыщенность (S)', i: 'Интенсивность (I)' },
|
||||||
|
display: { gray: 'Градациями серого', color: 'Пространство как RGB' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'png-to-cmyk': {
|
||||||
|
title: 'PNG в CMYK-цвета',
|
||||||
|
description:
|
||||||
|
'Раскладывает изображение на печатные компоненты: Голубой, Пурпурный, Жёлтый и Чёрный (Key).',
|
||||||
|
params: { component: 'Компонент', display: 'Режим показа' },
|
||||||
|
options: {
|
||||||
|
component: {
|
||||||
|
c: 'Голубой (C)',
|
||||||
|
m: 'Пурпурный (M)',
|
||||||
|
y: 'Жёлтый (Y)',
|
||||||
|
k: 'Чёрный (K)'
|
||||||
|
},
|
||||||
|
display: { gray: 'Градациями серого', color: 'Пространство как RGB' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'png-to-ycbcr': {
|
||||||
|
title: 'PNG в YCbCr-цвета',
|
||||||
|
description:
|
||||||
|
'Раскладывает изображение на Яркость (Y) и цветоразностные компоненты Cb / Cr.',
|
||||||
|
params: { component: 'Компонент', display: 'Режим показа' },
|
||||||
|
options: {
|
||||||
|
component: { y: 'Яркость (Y)', cb: 'Синий-разностный (Cb)', cr: 'Красный-разностный (Cr)' },
|
||||||
|
display: { gray: 'Градациями серого', color: 'Пространство как RGB' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'png-to-lab': {
|
||||||
|
title: 'PNG в LAB-цвета',
|
||||||
|
description:
|
||||||
|
'Раскладывает изображение на perceptual-компоненты: Светлость, зелёный–пурпурный и синий–жёлтый.',
|
||||||
|
params: { component: 'Компонент', display: 'Режим показа' },
|
||||||
|
options: {
|
||||||
|
component: {
|
||||||
|
l: 'Светлость (L)',
|
||||||
|
a: 'Зелёный–пурпурный (a)',
|
||||||
|
b: 'Синий–жёлтый (b)'
|
||||||
|
},
|
||||||
|
display: { gray: 'Градациями серого', color: 'Пространство как RGB' }
|
||||||
|
}
|
||||||
|
},
|
||||||
'watermark-tile-png': {
|
'watermark-tile-png': {
|
||||||
title: 'Плитка-водяной знак PNG',
|
title: 'Плитка-водяной знак PNG',
|
||||||
description:
|
description:
|
||||||
|
|||||||
+91
-2
@@ -76,7 +76,8 @@ import {
|
|||||||
tetradicSet,
|
tetradicSet,
|
||||||
triadicSet,
|
triadicSet,
|
||||||
type SortKey
|
type SortKey
|
||||||
} from './core/palette';
|
} from './core/palette';
|
||||||
|
import { renderSpace, SPACES, type SpaceId } from './core/channels';
|
||||||
import { hexToPixels, pixelsToHex } from './core/text';
|
import { hexToPixels, pixelsToHex } from './core/text';
|
||||||
import { clonePixelImage, type PixelImage } from './core/types';
|
import { clonePixelImage, type PixelImage } from './core/types';
|
||||||
|
|
||||||
@@ -173,6 +174,93 @@ function bool(params: Record<string, unknown>, id: string): boolean {
|
|||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SpaceEntry = {
|
||||||
|
id: SpaceId;
|
||||||
|
suffix: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNEL_SPACES: SpaceEntry[] = [
|
||||||
|
{
|
||||||
|
id: 'hsl',
|
||||||
|
suffix: 'hsl',
|
||||||
|
title: 'Split PNG into HSL',
|
||||||
|
description: 'Decomposes the image into Hue, Saturation and Lightness components.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hsv',
|
||||||
|
suffix: 'hsv',
|
||||||
|
title: 'Split PNG into HSV',
|
||||||
|
description: 'Decomposes the image into Hue, Saturation and Value (brightness) components.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hsi',
|
||||||
|
suffix: 'hsi',
|
||||||
|
title: 'Split PNG into HSI',
|
||||||
|
description: 'Decomposes the image into Hue, Saturation and Intensity components.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cmyk',
|
||||||
|
suffix: 'cmyk',
|
||||||
|
title: 'Convert PNG to CMYK Colors',
|
||||||
|
description:
|
||||||
|
'Decomposes the image into print-style Cyan, Magenta, Yellow and Key (black) components.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ycbcr',
|
||||||
|
suffix: 'ycbcr',
|
||||||
|
title: 'Convert PNG to YCbCr Colors',
|
||||||
|
description:
|
||||||
|
'Decomposes the image into Luma (Y) and Blue-difference / Red-difference chroma components.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lab',
|
||||||
|
suffix: 'lab',
|
||||||
|
title: 'Convert PNG to LAB Colors',
|
||||||
|
description:
|
||||||
|
'Decomposes the image into perceptual Lightness and green–magenta / blue–yellow opponents.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function channelEntries(): ToolEntry[] {
|
||||||
|
return CHANNEL_SPACES.map((space) => {
|
||||||
|
const components = SPACES[space.id].components;
|
||||||
|
return {
|
||||||
|
id: `png-to-${space.suffix}`,
|
||||||
|
title: space.title,
|
||||||
|
description: space.description,
|
||||||
|
category: 'color' as const,
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
id: 'component',
|
||||||
|
label: 'Component',
|
||||||
|
type: 'select' as const,
|
||||||
|
default: components[0],
|
||||||
|
options: components.map((c) => ({ value: c, label: c.toUpperCase() }))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'display',
|
||||||
|
label: 'Display mode',
|
||||||
|
type: 'select' as const,
|
||||||
|
default: 'gray',
|
||||||
|
options: [
|
||||||
|
{ value: 'gray', label: 'Grayscale' },
|
||||||
|
{ value: 'color', label: 'Space as RGB' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
run: (img, p) =>
|
||||||
|
renderSpace(
|
||||||
|
img,
|
||||||
|
space.id,
|
||||||
|
str(p, 'component'),
|
||||||
|
str(p, 'display') === 'color' ? 'color' : 'gray'
|
||||||
|
)
|
||||||
|
} satisfies ToolEntry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function paletteParams(baseDefault: string) {
|
function paletteParams(baseDefault: string) {
|
||||||
return [
|
return [
|
||||||
{ id: 'baseColor', label: 'Base color', type: 'color' as const, default: baseDefault },
|
{ id: 'baseColor', label: 'Base color', type: 'color' as const, default: baseDefault },
|
||||||
@@ -223,7 +311,8 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TOOLS: ToolEntry[] = [
|
export const TOOLS: ToolEntry[] = [
|
||||||
|
...channelEntries(),
|
||||||
decodeToPng(
|
decodeToPng(
|
||||||
'jpg-to-png',
|
'jpg-to-png',
|
||||||
'Convert JPG to PNG',
|
'Convert JPG to PNG',
|
||||||
|
|||||||
@@ -99,6 +99,12 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
|
|||||||
'add-stroke-png': Square,
|
'add-stroke-png': Square,
|
||||||
'add-text-png': Type,
|
'add-text-png': Type,
|
||||||
'date-stamp-png': CalendarDays,
|
'date-stamp-png': CalendarDays,
|
||||||
|
'png-to-hsl': Blend,
|
||||||
|
'png-to-hsv': Droplet,
|
||||||
|
'png-to-hsi': Focus,
|
||||||
|
'png-to-cmyk': PaintBucket,
|
||||||
|
'png-to-ycbcr': Layers,
|
||||||
|
'png-to-lab': Ruler,
|
||||||
'watermark-tile-png': Stamp,
|
'watermark-tile-png': Stamp,
|
||||||
'watermark-image-png': FileImage,
|
'watermark-image-png': FileImage,
|
||||||
'color-wheel-png': Rainbow,
|
'color-wheel-png': Rainbow,
|
||||||
|
|||||||
Reference in New Issue
Block a user