feat: add core operations
This commit is contained in:
@@ -1,7 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { colorMask, flattenOntoColor, parseHex, removeColorToAlpha } from './alpha';
|
import { colorMask, flattenOntoColor, invertAlpha, parseHex, removeColorToAlpha } from './alpha';
|
||||||
import { makeImage } from './test-helpers';
|
import { makeImage } from './test-helpers';
|
||||||
|
|
||||||
|
describe('invertAlpha', () => {
|
||||||
|
it('обращает альфу, RGB не трогает', () => {
|
||||||
|
const out = invertAlpha(makeImage(1, 1, [[10, 20, 30, 128]]));
|
||||||
|
expect([...out.data]).toEqual([10, 20, 30, 127]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('removeColorToAlpha', () => {
|
describe('removeColorToAlpha', () => {
|
||||||
const fixture = () =>
|
const fixture = () =>
|
||||||
makeImage(2, 1, [
|
makeImage(2, 1, [
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ export function removeColorToAlpha(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function invertAlpha(img: PixelImage): PixelImage {
|
||||||
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
|
out.data[i] = img.data[i];
|
||||||
|
out.data[i + 1] = img.data[i + 1];
|
||||||
|
out.data[i + 2] = img.data[i + 2];
|
||||||
|
out.data[i + 3] = 255 - img.data[i + 3];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function colorMask(img: PixelImage, hex: string, tolerancePercent = 0): PixelImage {
|
export function colorMask(img: PixelImage, hex: string, tolerancePercent = 0): PixelImage {
|
||||||
const [targetR, targetG, targetB] = parseHex(hex);
|
const [targetR, targetG, targetB] = parseHex(hex);
|
||||||
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
|
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { brightnessContrast, grayscale, invert, rgbToHex } from './color';
|
import {
|
||||||
|
brightnessContrast,
|
||||||
|
changeHue,
|
||||||
|
extractChannel,
|
||||||
|
grayscale,
|
||||||
|
invert,
|
||||||
|
posterize,
|
||||||
|
rgbToHex,
|
||||||
|
sepia,
|
||||||
|
setOpacity,
|
||||||
|
swapChannels,
|
||||||
|
thresholdBlackWhite,
|
||||||
|
twoColors
|
||||||
|
} from './color';
|
||||||
import { makeImage } from './test-helpers';
|
import { makeImage } from './test-helpers';
|
||||||
|
|
||||||
describe('rgbToHex', () => {
|
describe('rgbToHex', () => {
|
||||||
@@ -13,6 +26,98 @@ describe('rgbToHex', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('setOpacity', () => {
|
||||||
|
it('умножает альфу на процент, RGB не трогает', () => {
|
||||||
|
const out = setOpacity(makeImage(1, 1, [[10, 20, 30, 128]]), 50);
|
||||||
|
expect([...out.data]).toEqual([10, 20, 30, 64]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('100% не меняет, 0% делает полностью прозрачным', () => {
|
||||||
|
expect(setOpacity(makeImage(1, 1, [[1, 2, 3, 200]]), 100).data[3]).toBe(200);
|
||||||
|
expect(setOpacity(makeImage(1, 1, [[1, 2, 3, 200]]), 0).data[3]).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sepia', () => {
|
||||||
|
it('применяет классическую матрицу с клампом', () => {
|
||||||
|
const out = sepia(makeImage(2, 1, [
|
||||||
|
[255, 0, 0, 255],
|
||||||
|
[255, 255, 255, 255]
|
||||||
|
]));
|
||||||
|
const px = (n: number) => [...out.data.slice(n * 4, n * 4 + 4)];
|
||||||
|
expect(px(0)).toEqual([100, 89, 69, 255]);
|
||||||
|
expect(px(1)[0]).toBe(255);
|
||||||
|
expect(out.data[7]).toBe(255);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('changeHue', () => {
|
||||||
|
it('чистый красный при +120° становится чистым зелёным', () => {
|
||||||
|
const out = changeHue(makeImage(1, 1, [[255, 0, 0, 255]]), 120);
|
||||||
|
expect([...out.data]).toEqual([0, 255, 0, 255]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('сдвиг 360° возвращает исходные цвета', () => {
|
||||||
|
const img = makeImage(1, 1, [[90, 140, 210, 255]]);
|
||||||
|
expect([...changeHue(img, 360).data]).toEqual([...img.data]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractChannel', () => {
|
||||||
|
it('выдаёт выбранный канал оттенками серого', () => {
|
||||||
|
const out = extractChannel(makeImage(1, 1, [[10, 20, 30, 40]]), 'green');
|
||||||
|
expect([...out.data]).toEqual([20, 20, 20, 40]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('swapChannels', () => {
|
||||||
|
it('переставляет каналы парами', () => {
|
||||||
|
expect([...swapChannels(makeImage(1, 1, [[10, 20, 30, 40]]), 'r-b').data]).toEqual([
|
||||||
|
30, 20, 10, 40
|
||||||
|
]);
|
||||||
|
expect([...swapChannels(makeImage(1, 1, [[10, 20, 30, 40]]), 'g-b').data]).toEqual([
|
||||||
|
10, 30, 20, 40
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('thresholdBlackWhite', () => {
|
||||||
|
it('серый 128 относительно порога 50% — белый', () => {
|
||||||
|
expect(thresholdBlackWhite(makeImage(1, 1, [[128, 128, 128, 255]]), 50).data[0]).toBe(255);
|
||||||
|
expect(thresholdBlackWhite(makeImage(1, 1, [[128, 128, 128, 255]]), 60).data[0]).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('posterize', () => {
|
||||||
|
it('два уровня квантуют в чёрное и белое', () => {
|
||||||
|
const out = posterize(
|
||||||
|
makeImage(2, 1, [
|
||||||
|
[100, 100, 100, 255],
|
||||||
|
[200, 200, 200, 255]
|
||||||
|
]),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
expect(out.data[0]).toBe(0);
|
||||||
|
expect(out.data[4]).toBe(255);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('twoColors', () => {
|
||||||
|
it('яркие пиксели получают светлый цвет, тёмные — тёмный', () => {
|
||||||
|
const out = twoColors(
|
||||||
|
makeImage(2, 1, [
|
||||||
|
[250, 250, 250, 255],
|
||||||
|
[10, 10, 10, 128]
|
||||||
|
]),
|
||||||
|
'#ff0000',
|
||||||
|
'#00ff00',
|
||||||
|
50
|
||||||
|
);
|
||||||
|
expect([...out.data.slice(0, 4)]).toEqual([255, 0, 0, 255]);
|
||||||
|
expect([...out.data.slice(4, 8)]).toEqual([0, 255, 0, 128]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('grayscale', () => {
|
describe('grayscale', () => {
|
||||||
it('считает luma по весам BT.601 с округлением', () => {
|
it('считает luma по весам BT.601 с округлением', () => {
|
||||||
const out = grayscale(
|
const out = grayscale(
|
||||||
|
|||||||
+174
-1
@@ -1,4 +1,177 @@
|
|||||||
import { createPixelImage, type PixelImage } from './types';
|
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||||
|
|
||||||
|
export type RgbChannel = 'red' | 'green' | 'blue';
|
||||||
|
|
||||||
|
export type ChannelSwapPair = 'r-g' | 'r-b' | 'g-b';
|
||||||
|
|
||||||
|
export function setOpacity(img: PixelImage, percent: number): PixelImage {
|
||||||
|
const factor = clamp(percent, 0, 100) / 100;
|
||||||
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
|
out.data[i] = img.data[i];
|
||||||
|
out.data[i + 1] = img.data[i + 1];
|
||||||
|
out.data[i + 2] = img.data[i + 2];
|
||||||
|
out.data[i + 3] = img.data[i + 3] * factor;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sepia(img: PixelImage): PixelImage {
|
||||||
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
|
const r = img.data[i];
|
||||||
|
const g = img.data[i + 1];
|
||||||
|
const b = img.data[i + 2];
|
||||||
|
out.data[i] = Math.min(255, 0.393 * r + 0.769 * g + 0.189 * b);
|
||||||
|
out.data[i + 1] = Math.min(255, 0.349 * r + 0.686 * g + 0.168 * b);
|
||||||
|
out.data[i + 2] = Math.min(255, 0.272 * r + 0.534 * g + 0.131 * b);
|
||||||
|
out.data[i + 3] = img.data[i + 3];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changeHue(img: PixelImage, degrees: number): PixelImage {
|
||||||
|
const shift = (((Math.round(degrees) % 360) + 360) % 360) / 360;
|
||||||
|
if (shift === 0) return clonePixelImage(img);
|
||||||
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
|
const rf = img.data[i] / 255;
|
||||||
|
const gf = img.data[i + 1] / 255;
|
||||||
|
const bf = img.data[i + 2] / 255;
|
||||||
|
const max = Math.max(rf, gf, bf);
|
||||||
|
const min = Math.min(rf, gf, bf);
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
let h = 0;
|
||||||
|
let s = 0;
|
||||||
|
if (max !== min) {
|
||||||
|
const d = max - min;
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
if (max === rf) h = (gf - bf) / d + (gf < bf ? 6 : 0);
|
||||||
|
else if (max === gf) h = (bf - rf) / d + 2;
|
||||||
|
else h = (rf - gf) / d + 4;
|
||||||
|
h /= 6;
|
||||||
|
}
|
||||||
|
h = (h + shift) % 1;
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
out.data[i] = hueComponent(p, q, h + 1 / 3) * 255;
|
||||||
|
out.data[i + 1] = hueComponent(p, q, h) * 255;
|
||||||
|
out.data[i + 2] = hueComponent(p, q, h - 1 / 3) * 255;
|
||||||
|
out.data[i + 3] = img.data[i + 3];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hueComponent(p: number, q: number, t: number): 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 CHANNEL_INDEX: Record<RgbChannel, number> = { red: 0, green: 1, blue: 2 };
|
||||||
|
|
||||||
|
export function extractChannel(img: PixelImage, channel: RgbChannel): PixelImage {
|
||||||
|
const index = CHANNEL_INDEX[channel];
|
||||||
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
|
const v = img.data[i + index];
|
||||||
|
out.data[i] = v;
|
||||||
|
out.data[i + 1] = v;
|
||||||
|
out.data[i + 2] = v;
|
||||||
|
out.data[i + 3] = img.data[i + 3];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SWAP_INDEX: Record<ChannelSwapPair, [number, number]> = {
|
||||||
|
'r-g': [0, 1],
|
||||||
|
'r-b': [0, 2],
|
||||||
|
'g-b': [1, 2]
|
||||||
|
};
|
||||||
|
|
||||||
|
export function swapChannels(img: PixelImage, pair: ChannelSwapPair): PixelImage {
|
||||||
|
const [a, b] = SWAP_INDEX[pair];
|
||||||
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
|
out.data[i] = img.data[i];
|
||||||
|
out.data[i + 1] = img.data[i + 1];
|
||||||
|
out.data[i + 2] = img.data[i + 2];
|
||||||
|
out.data[i + 3] = img.data[i + 3];
|
||||||
|
const tmp = out.data[i + a];
|
||||||
|
out.data[i + a] = out.data[i + b];
|
||||||
|
out.data[i + b] = tmp;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function thresholdBlackWhite(img: PixelImage, thresholdPercent: number): PixelImage {
|
||||||
|
const threshold = (clamp(thresholdPercent, 0, 100) / 100) * 255;
|
||||||
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
|
const luma = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
|
||||||
|
const v = luma >= threshold ? 255 : 0;
|
||||||
|
out.data[i] = v;
|
||||||
|
out.data[i + 1] = v;
|
||||||
|
out.data[i + 2] = v;
|
||||||
|
out.data[i + 3] = img.data[i + 3];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function posterize(img: PixelImage, levels: number): PixelImage {
|
||||||
|
const n = clamp(Math.round(levels), 2, 256);
|
||||||
|
const stepSize = 255 / (n - 1);
|
||||||
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
|
for (let ch = 0; ch < 3; ch++) {
|
||||||
|
out.data[i + ch] = Math.round(Math.round(img.data[i + ch] / stepSize) * stepSize);
|
||||||
|
}
|
||||||
|
out.data[i + 3] = img.data[i + 3];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function twoColors(
|
||||||
|
img: PixelImage,
|
||||||
|
lightHex: string,
|
||||||
|
darkHex: string,
|
||||||
|
thresholdPercent: number
|
||||||
|
): PixelImage {
|
||||||
|
const [lr, lg, lb] = parseColor(lightHex);
|
||||||
|
const [dr, dg, db] = parseColor(darkHex);
|
||||||
|
const threshold = (clamp(thresholdPercent, 0, 100) / 100) * 255;
|
||||||
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
|
const luma = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2];
|
||||||
|
if (luma >= threshold) {
|
||||||
|
out.data[i] = lr;
|
||||||
|
out.data[i + 1] = lg;
|
||||||
|
out.data[i + 2] = lb;
|
||||||
|
} else {
|
||||||
|
out.data[i] = dr;
|
||||||
|
out.data[i + 1] = dg;
|
||||||
|
out.data[i + 2] = db;
|
||||||
|
}
|
||||||
|
out.data[i + 3] = img.data[i + 3];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseColor(hex: string): [number, number, number] {
|
||||||
|
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Некорректный HEX-цвет: "${hex}"`);
|
||||||
|
}
|
||||||
|
const digits = match[1];
|
||||||
|
return [
|
||||||
|
parseInt(digits.slice(0, 2), 16),
|
||||||
|
parseInt(digits.slice(2, 4), 16),
|
||||||
|
parseInt(digits.slice(4, 6), 16)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function grayscale(img: PixelImage): PixelImage {
|
export function grayscale(img: PixelImage): PixelImage {
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const PLAN_TOOL_IDS = [
|
|||||||
'convert-png-to-webp',
|
'convert-png-to-webp',
|
||||||
'remove-color-from-png',
|
'remove-color-from-png',
|
||||||
'png-info',
|
'png-info',
|
||||||
|
'invert-alpha-png',
|
||||||
'jpg-to-png',
|
'jpg-to-png',
|
||||||
'webp-to-png',
|
'webp-to-png',
|
||||||
'gif-to-png',
|
'gif-to-png',
|
||||||
@@ -33,11 +34,19 @@ const PLAN_TOOL_IDS = [
|
|||||||
'png-to-data-uri',
|
'png-to-data-uri',
|
||||||
'data-uri-to-png',
|
'data-uri-to-png',
|
||||||
'png-to-hex',
|
'png-to-hex',
|
||||||
'hex-to-png'
|
'hex-to-png',
|
||||||
|
'change-png-opacity',
|
||||||
|
'sepia-png',
|
||||||
|
'change-png-hue',
|
||||||
|
'extract-channel-png',
|
||||||
|
'swap-channels-png',
|
||||||
|
'black-and-white-png',
|
||||||
|
'posterize-png',
|
||||||
|
'two-colors-png'
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('реестр инструментов', () => {
|
describe('реестр инструментов', () => {
|
||||||
it('содержит ровно 23 инструмента из плана', () => {
|
it('содержит ровно 32 инструмента из плана', () => {
|
||||||
expect(TOOLS.map((t) => t.id).sort()).toEqual([...PLAN_TOOL_IDS].sort());
|
expect(TOOLS.map((t) => t.id).sort()).toEqual([...PLAN_TOOL_IDS].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+125
-2
@@ -1,6 +1,20 @@
|
|||||||
import type { CategoryId } from './categories';
|
import type { CategoryId } from './categories';
|
||||||
import { colorMask, flattenOntoColor, removeColorToAlpha } from './core/alpha';
|
import { colorMask, flattenOntoColor, invertAlpha, removeColorToAlpha } from './core/alpha';
|
||||||
import { brightnessContrast, grayscale, invert } from './core/color';
|
import {
|
||||||
|
brightnessContrast,
|
||||||
|
changeHue,
|
||||||
|
extractChannel,
|
||||||
|
grayscale,
|
||||||
|
invert,
|
||||||
|
posterize,
|
||||||
|
sepia,
|
||||||
|
setOpacity,
|
||||||
|
swapChannels,
|
||||||
|
thresholdBlackWhite,
|
||||||
|
twoColors,
|
||||||
|
type ChannelSwapPair,
|
||||||
|
type RgbChannel
|
||||||
|
} from './core/color';
|
||||||
import { crop, flip, resize, rotate90 } from './core/geometry';
|
import { crop, flip, resize, rotate90 } from './core/geometry';
|
||||||
import { decodeTextImage, toBase64, toDataUrl, type OutputMime } from './core/io';
|
import { decodeTextImage, toBase64, toDataUrl, type OutputMime } from './core/io';
|
||||||
import { hexToPixels, pixelsToHex } from './core/text';
|
import { hexToPixels, pixelsToHex } from './core/text';
|
||||||
@@ -306,6 +320,107 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
],
|
],
|
||||||
run: (img, p) => brightnessContrast(img, num(p, 'brightness'), num(p, 'contrast'))
|
run: (img, p) => brightnessContrast(img, num(p, 'brightness'), num(p, 'contrast'))
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'change-png-opacity',
|
||||||
|
title: 'Изменить прозрачность PNG',
|
||||||
|
description:
|
||||||
|
'Умножает альфа-канал на процент: 0% — полностью прозрачный, 100% — без изменений.',
|
||||||
|
category: 'color',
|
||||||
|
params: [
|
||||||
|
{ id: 'percent', label: 'Прозрачность, %', type: 'slider', min: 0, max: 100, step: 1, default: 100 }
|
||||||
|
],
|
||||||
|
run: (img, p) => setOpacity(img, num(p, 'percent'))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sepia-png',
|
||||||
|
title: 'Эффект сепии',
|
||||||
|
description: 'Тонирует изображение в тёплые коричневые тона классической сепии.',
|
||||||
|
category: 'color',
|
||||||
|
params: [],
|
||||||
|
run: (img) => sepia(img)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'change-png-hue',
|
||||||
|
title: 'Сменить оттенок PNG',
|
||||||
|
description: 'Сдвиг цветового тона по кругу. Насыщенность и яркость сохраняются.',
|
||||||
|
category: 'color',
|
||||||
|
params: [
|
||||||
|
{ id: 'degrees', label: 'Сдвиг тона, °', type: 'slider', min: -180, max: 180, step: 1, default: 0 }
|
||||||
|
],
|
||||||
|
run: (img, p) => changeHue(img, num(p, 'degrees'))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'extract-channel-png',
|
||||||
|
title: 'Извлечь канал PNG',
|
||||||
|
description: 'Оставляет выбранный канал — красный, зелёный или синий — в оттенках серого.',
|
||||||
|
category: 'color',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
id: 'channel',
|
||||||
|
label: 'Канал',
|
||||||
|
type: 'select',
|
||||||
|
default: 'red',
|
||||||
|
options: [
|
||||||
|
{ value: 'red', label: 'Красный' },
|
||||||
|
{ value: 'green', label: 'Зелёный' },
|
||||||
|
{ value: 'blue', label: 'Синий' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
run: (img, p) => extractChannel(img, str(p, 'channel') as RgbChannel)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'swap-channels-png',
|
||||||
|
title: 'Переставить каналы PNG',
|
||||||
|
description: 'Меняет местами два цветовых канала — быстрый способ получить необычный окрас.',
|
||||||
|
category: 'color',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
id: 'pair',
|
||||||
|
label: 'Пара каналов',
|
||||||
|
type: 'select',
|
||||||
|
default: 'r-g',
|
||||||
|
options: [
|
||||||
|
{ value: 'r-g', label: 'Красный ↔ Зелёный' },
|
||||||
|
{ value: 'r-b', label: 'Красный ↔ Синий' },
|
||||||
|
{ value: 'g-b', label: 'Зелёный ↔ Синий' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
run: (img, p) => swapChannels(img, str(p, 'pair') as ChannelSwapPair)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'black-and-white-png',
|
||||||
|
title: 'Чёрно-белый PNG по порогу',
|
||||||
|
description: 'Жёсткая бинаризация по яркости: каждый пиксель становится чёрным или белым.',
|
||||||
|
category: 'color',
|
||||||
|
params: [
|
||||||
|
{ id: 'threshold', label: 'Порог яркости, %', type: 'slider', min: 0, max: 100, step: 1, default: 50 }
|
||||||
|
],
|
||||||
|
run: (img, p) => thresholdBlackWhite(img, num(p, 'threshold'))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'posterize-png',
|
||||||
|
title: 'Постеризация PNG',
|
||||||
|
description: 'Уменьшает число уровней каждого канала — плакатный эффект.',
|
||||||
|
category: 'color',
|
||||||
|
params: [
|
||||||
|
{ id: 'levels', label: 'Уровней на канал', type: 'slider', min: 2, max: 16, step: 1, default: 4 }
|
||||||
|
],
|
||||||
|
run: (img, p) => posterize(img, num(p, 'levels'))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'two-colors-png',
|
||||||
|
title: 'Два цвета PNG',
|
||||||
|
description: 'Перекрашивает изображение в два выбранных цвета по порогу яркости.',
|
||||||
|
category: 'color',
|
||||||
|
params: [
|
||||||
|
{ id: 'lightColor', label: 'Цвет светлых участков', type: 'color', default: '#ffffff' },
|
||||||
|
{ id: 'darkColor', label: 'Цвет тёмных участков', type: 'color', default: '#000000' },
|
||||||
|
{ id: 'threshold', label: 'Порог яркости, %', type: 'slider', min: 0, max: 100, step: 1, default: 50 }
|
||||||
|
],
|
||||||
|
run: (img, p) => twoColors(img, str(p, 'lightColor'), str(p, 'darkColor'), num(p, 'threshold'))
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'convert-png-to-jpg',
|
id: 'convert-png-to-jpg',
|
||||||
title: 'Конвертировать PNG в JPG',
|
title: 'Конвертировать PNG в JPG',
|
||||||
@@ -328,6 +443,14 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
output: { mime: 'image/webp', ext: 'webp', qualityParamId: 'quality' },
|
output: { mime: 'image/webp', ext: 'webp', qualityParamId: 'quality' },
|
||||||
run: (img) => clonePixelImage(img)
|
run: (img) => clonePixelImage(img)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'invert-alpha-png',
|
||||||
|
title: 'Инвертировать альфа-канал PNG',
|
||||||
|
description: 'Непрозрачные области становятся прозрачными и наоборот.',
|
||||||
|
category: 'alpha',
|
||||||
|
params: [],
|
||||||
|
run: (img) => invertAlpha(img)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'remove-color-from-png',
|
id: 'remove-color-from-png',
|
||||||
title: 'Удалить цвет из PNG (прозрачность)',
|
title: 'Удалить цвет из PNG (прозрачность)',
|
||||||
|
|||||||
Reference in New Issue
Block a user