Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3aca194c7 | ||
|
|
d64eaa0e93 | ||
|
|
ebc3c33c12 | ||
|
|
8745812a5f | ||
|
|
0909e14cde | ||
|
|
7ef4b908e2 | ||
|
|
b6e157e5da |
@@ -0,0 +1,57 @@
|
||||
# План: третья волна EASY — трансформации, свет, SVG
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 24.08.2026.**
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Одиннадцать новых инструментов: свободные трансформации формы (наклон, поворот на произвольный угол, зум, сдвиг), светокоррекция (гамма, автоконтраст, температура, тонирование), виньетка, эффект «артефактов сжатия» и вход SVG. Каталог вырастает с 58 до ~69.
|
||||
|
||||
Текстовые инструменты и водяные знаки в волну не входят — им нужен рендер текста на канвасе, это отдельная четвёртая волна.
|
||||
|
||||
## 2. Новое ядро
|
||||
|
||||
- **Аффинные трансформации** (`core/affine.ts`): единая функция обратного отображения — для каждого пикселя результата вычисляется исходная координата матрицей и семплируется билинейно (общий семплер, вынесенный из `resize`). Поверх неё: наклон по осям, поворот на произвольный угол (с расширением холста под новые габариты), зум к центру, сдвиг.
|
||||
- **Свет и цвет** (`core/color.ts` дополнения): гамма-коррекция через LUT, автоконтраст (растяжка по гистограмме яркости с отсечкой хвостов), температура (множители тёплых/холодных каналов), тонирование (умножение RGB на цвет с сохранением яркостного баланса).
|
||||
- **Виньетка** (`core/effects.ts`): радиальное затемнение от центра, сила слайдером; чистая математика без canvas.
|
||||
- **IO-расширения** (`core/io.ts`): декодирование SVG из текста/файла через blob-URL и `Image`; раундтрип «закодировать JPEG с качеством Q → декодировать обратно» для эффекта артефактов сжатия.
|
||||
|
||||
## 3. Инструменты — 11
|
||||
|
||||
- **Геометрия:** `skew-png` (углы X/Y), `rotate-free-png` (любой угол, холст расширяется), `zoom-png` (масштаб ≥100 % к центру), `shift-png` (сдвиг со смещением содержимого).
|
||||
- **Фильтры:** `vignette-png` (сила затемнения), `jpeg-artifacts-png` (имитация пережатия).
|
||||
- **Цвет:** `gamma-png` (0.1–3.0), `auto-contrast-png` (без параметров), `temperature-png` (−100 тёплый…+100 холодный или наоборот — подпись честная), `tint-png` (цвет + сила).
|
||||
- **Конвертация:** `svg-to-png` (текст SVG слева, как base64-режим).
|
||||
|
||||
## 4. Этапы и проверки
|
||||
|
||||
- **A. Ядро** — семплер, аффинный модуль, свет/цвет, эффекты + точные тесты: поворот квадрата на 90° совпадает с существующим `rotate90`, зум ×2 удваивает размеры и центр остаётся центром, гамма 1 — идентичность, автоконтраст растягивает известный диапазон, виньетка симметрична и не трогает центр при нулевой силе. Проверка: `pnpm test/check`.
|
||||
- **B. IO** — SVG-декод и JPEG-раундтрип. Проверка: сборка; живой прогон на этапе D.
|
||||
- **C. Реестр** — одиннадцать записей, иконки, тесты целостности. Проверка: `test/build`, страницы в экспорте.
|
||||
- **D. Итог** — полный прогон, смоук §7.
|
||||
|
||||
## 5. Смоук-чеклист
|
||||
|
||||
1. Наклон X/Y по отдельности и вместе — форма искажается предсказуемо, без чёрных полос при малых углах.
|
||||
2. Поворот на 30° — холст вырос, углы прозрачны, содержимое повернуто.
|
||||
3. Зум 200 % — содержимое увеличено к центру, холст прежнего размера, края обрезаны.
|
||||
4. Сдвиг уводит содержимое за край, противоположный край заполняется фоном.
|
||||
5. Гамма 0.5 темнее, 2.0 светлее, 1.0 — без изменений.
|
||||
6. Автоконтраст на тусклом фото вытягивает диапазон.
|
||||
7. Температура ±50 заметно теплеет/холодеет.
|
||||
8. Тонирование красным даёт равномерный оттенок, белые области окрашиваются сильнее всего.
|
||||
9. Виньетка затемняет только края, центр не трогает.
|
||||
10. Артефакты: качество 10 даёт видимые квадраты JPEG.
|
||||
11. SVG вставленный слева декодируется в результат справа; битый SVG — баннер.
|
||||
12. Все новые инструменты работают в цепочке и скачиваются из своих секций.
|
||||
|
||||
## 6. Критерии готовности
|
||||
|
||||
- Одиннадцать инструментов работают и покрыты юнит-тестами ядра (кроме IO-дом-части).
|
||||
- Аффинные операции опираются на общий билинейный семплер; дублирования сэмплинга нет.
|
||||
- Каталог ~69 инструментов; `test/check/build` зелёные, смоук §5 пройден.
|
||||
|
||||
## 7. Что сознательно не делаем
|
||||
|
||||
- Рендер текста, водяные знаки, штампы — четвёртая волна (DOM-текст).
|
||||
- Субпиксельная точность аффинных трансформаций сверх билинейного семплинга.
|
||||
- Пакетное применение и пресеты.
|
||||
@@ -1,6 +1,6 @@
|
||||
# План: MEDIUM-волна №2 — морфология прозрачности
|
||||
|
||||
> Статус: план к выполнению.
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
|
||||
|
||||
## 1. Зачем
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { rotate90, sampleBilinear } from './geometry';
|
||||
import { rotateFreeImage, skewImage, transformImage, zoomImage } from './affine';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
describe('sampleBilinear', () => {
|
||||
it('целые координаты возвращают точный пиксель', () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[255, 0, 0, 255],
|
||||
[0, 0, 255, 255]
|
||||
]);
|
||||
expect(sampleBilinear(img, 0, 0)).toEqual([255, 0, 0, 255]);
|
||||
expect(sampleBilinear(img, 1, 0)).toEqual([0, 0, 255, 255]);
|
||||
});
|
||||
|
||||
it('дробная координата интерполирует', () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[0, 0, 0, 255],
|
||||
[200, 200, 200, 255]
|
||||
]);
|
||||
const [r] = sampleBilinear(img, 0.5, 0);
|
||||
expect(r).toBeCloseTo(100, 0);
|
||||
});
|
||||
|
||||
it('координаты за краем клампятся', () => {
|
||||
const img = makeImage(1, 1, [[7, 7, 7, 255]]);
|
||||
expect(sampleBilinear(img, -10, -10)).toEqual([7, 7, 7, 255]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotateFreeImage', () => {
|
||||
it('поворот на 180° даёт размеры не меньше исходных и непустой результат', () => {
|
||||
const img = makeImage(4, 3, [
|
||||
[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 0, 255],
|
||||
[128, 128, 128, 255], [64, 64, 64, 255], [32, 32, 32, 255], [200, 200, 200, 255],
|
||||
[10, 20, 30, 255], [40, 50, 60, 255], [70, 80, 90, 255], [100, 100, 100, 255]
|
||||
]);
|
||||
const out = rotateFreeImage(img, 180);
|
||||
expect(out.width).toBeGreaterThanOrEqual(img.width);
|
||||
expect(out.height).toBeGreaterThanOrEqual(img.height);
|
||||
let opaque = 0;
|
||||
const total = out.width * out.height;
|
||||
for (let i = 3; i < out.data.length; i += 4) {
|
||||
if (out.data[i] > 0) opaque++;
|
||||
}
|
||||
expect(opaque / total).toBeGreaterThan(0.8);
|
||||
});
|
||||
|
||||
it('поворот квадрата на 360° близок к оригиналу', () => {
|
||||
const img = makeImage(
|
||||
5,
|
||||
5,
|
||||
new Array(25).fill([200, 100, 50, 255])
|
||||
);
|
||||
const out = rotateFreeImage(img, 360);
|
||||
expect(out.width).toBeGreaterThanOrEqual(5);
|
||||
expect(out.height).toBeGreaterThanOrEqual(5);
|
||||
for (let y = 0; y < 5; y++) {
|
||||
for (let x = 0; x < 5; x++) {
|
||||
const di = (y * out.width + x) * 4;
|
||||
for (let ch = 0; ch < 4; ch++) {
|
||||
expect(Math.abs(out.data[di + ch] - img.data[(y * 5 + x) * 4 + ch])).toBeLessThanOrEqual(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('skewImage', () => {
|
||||
it('наклон X=45° расширяет холст до w+h−1 и сдвигает строки вправо', () => {
|
||||
const img = makeImage(2, 2, new Array(4).fill([255, 0, 0, 255]));
|
||||
const out = skewImage(img, 45, 0);
|
||||
expect(out.width).toBe(3);
|
||||
expect(out.height).toBe(2);
|
||||
let opaque = 0;
|
||||
for (let i = 3; i < out.data.length; i += 4) {
|
||||
if (out.data[i] === 255) {
|
||||
opaque++;
|
||||
expect([out.data[i - 3], out.data[i - 2], out.data[i - 1]]).toEqual([255, 0, 0]);
|
||||
}
|
||||
}
|
||||
expect(opaque).toBe(4);
|
||||
});
|
||||
|
||||
it('углы 0° — тождественное преобразование', () => {
|
||||
const img = makeImage(2, 2, [
|
||||
[255, 0, 0, 255], [0, 255, 0, 255],
|
||||
[0, 0, 255, 255], [128, 128, 128, 255]
|
||||
]);
|
||||
const out = skewImage(img, 0, 0);
|
||||
expect(out.width).toBe(2);
|
||||
expect([...out.data]).toEqual([...img.data]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformImage — заливка фона', () => {
|
||||
it('сдвиг с белым фоном заполняет освободившийся край', () => {
|
||||
const img = makeImage(2, 2, [
|
||||
[255, 0, 0, 255], [0, 255, 0, 255],
|
||||
[0, 0, 255, 255], [128, 128, 128, 255]
|
||||
]);
|
||||
const out = transformImage(img, [1, 0, 0, 1, -1, 0], 2, 2, '#ffffff');
|
||||
expect(out.width).toBe(2);
|
||||
expect(out.height).toBe(2);
|
||||
for (let y = 0; y < 2; y++) {
|
||||
const di = (y * 2) * 4;
|
||||
expect([...out.data.slice(di, di + 4)]).toEqual([255, 255, 255, 255]);
|
||||
}
|
||||
});
|
||||
|
||||
it('без фона освободившийся край остаётся прозрачным', () => {
|
||||
const img = makeImage(2, 2, new Array(4).fill([10, 20, 30, 255]));
|
||||
const out = transformImage(img, [1, 0, 0, 1, -1, 0], 2, 2);
|
||||
expect(out.data[3]).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { parseHex } from './alpha';
|
||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||
import { sampleBilinear } from './geometry';
|
||||
|
||||
export type AffineMatrix = [number, number, number, number, number, number];
|
||||
|
||||
export function invertAffine([a, b, c, d, e, f]: AffineMatrix): AffineMatrix {
|
||||
const det = a * d - b * c;
|
||||
if (Math.abs(det) < 1e-12) {
|
||||
throw new Error('Вырожденная матрица трансформации');
|
||||
}
|
||||
const ia = d / det;
|
||||
const ib = -b / det;
|
||||
const ic = -c / det;
|
||||
const id = a / det;
|
||||
return [ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)];
|
||||
}
|
||||
|
||||
const EPS = 1e-9;
|
||||
|
||||
export function transformImage(
|
||||
img: PixelImage,
|
||||
dstToSrc: AffineMatrix,
|
||||
outWidth: number,
|
||||
outHeight: number,
|
||||
bgHex?: string
|
||||
): PixelImage {
|
||||
const [a, b, c, d, e, f] = dstToSrc;
|
||||
const out = createPixelImage(outWidth, outHeight);
|
||||
const bg = bgHex ? parseHex(bgHex) : null;
|
||||
for (let y = 0; y < outHeight; y++) {
|
||||
for (let x = 0; x < outWidth; x++) {
|
||||
const sx = a * x + c * y + e;
|
||||
const sy = b * x + d * y + f;
|
||||
const di = (y * outWidth + x) * 4;
|
||||
if (sx < -EPS || sy < -EPS || sx > img.width - 1 + EPS || sy > img.height - 1 + EPS) {
|
||||
if (bg) {
|
||||
out.data[di] = bg[0];
|
||||
out.data[di + 1] = bg[1];
|
||||
out.data[di + 2] = bg[2];
|
||||
out.data[di + 3] = 255;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const [r, g, bl, al] = sampleBilinear(img, sx, sy);
|
||||
if (bg) {
|
||||
const sa = al / 255;
|
||||
out.data[di] = r * sa + bg[0] * (1 - sa);
|
||||
out.data[di + 1] = g * sa + bg[1] * (1 - sa);
|
||||
out.data[di + 2] = bl * sa + bg[2] * (1 - sa);
|
||||
out.data[di + 3] = 255;
|
||||
} else {
|
||||
out.data[di] = r;
|
||||
out.data[di + 1] = g;
|
||||
out.data[di + 2] = bl;
|
||||
out.data[di + 3] = al;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function centeredTransform(img: PixelImage, forward: AffineMatrix): PixelImage {
|
||||
const inv = invertAffine(forward);
|
||||
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minY = Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const [px, py] of [
|
||||
[0.5, 0.5],
|
||||
[img.width - 0.5, 0.5],
|
||||
[0.5, img.height - 0.5],
|
||||
[img.width - 0.5, img.height - 0.5]
|
||||
]) {
|
||||
const qx = forward[0] * px + forward[2] * py + forward[4];
|
||||
const qy = forward[1] * px + forward[3] * py + forward[5];
|
||||
minX = Math.min(minX, qx);
|
||||
maxX = Math.max(maxX, qx);
|
||||
minY = Math.min(minY, qy);
|
||||
maxY = Math.max(maxY, qy);
|
||||
}
|
||||
const outW = Math.round(maxX - minX) + 1;
|
||||
const outH = Math.round(maxY - minY) + 1;
|
||||
const tx = inv[0] * minX + inv[2] * minY - 0.5;
|
||||
const ty = inv[1] * minX + inv[3] * minY - 0.5;
|
||||
return transformImage(img, [inv[0], inv[1], inv[2], inv[3], tx, ty], outW, outH);
|
||||
}
|
||||
|
||||
export function skewImage(img: PixelImage, degX: number, degY: number): PixelImage {
|
||||
const kx = Math.tan((degX * Math.PI) / 180);
|
||||
const ky = Math.tan((degY * Math.PI) / 180);
|
||||
if (!Number.isFinite(kx) || !Number.isFinite(ky)) {
|
||||
throw new Error('Углы наклона не могут быть 90° или -90°');
|
||||
}
|
||||
return centeredTransform(img, [1, ky, kx, 1, 0, 0]);
|
||||
}
|
||||
|
||||
export function rotateFreeImage(img: PixelImage, degrees: number): PixelImage {
|
||||
const rad = (degrees * Math.PI) / 180;
|
||||
let cos = Math.cos(rad);
|
||||
let sin = Math.sin(rad);
|
||||
if (Math.abs(sin) < 1e-12) sin = 0;
|
||||
if (Math.abs(cos) < 1e-12) cos = 0;
|
||||
return centeredTransform(img, [cos, sin, -sin, cos, 0, 0]);
|
||||
}
|
||||
|
||||
export function zoomImage(img: PixelImage, scalePercent: number): PixelImage {
|
||||
const scale = Math.min(Math.max(scalePercent, 100), 1000) / 100;
|
||||
if (scale === 1) return clonePixelImage(img);
|
||||
const cx = (img.width - 1) / 2;
|
||||
const cy = (img.height - 1) / 2;
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let y = 0; y < out.height; y++) {
|
||||
for (let x = 0; x < out.width; x++) {
|
||||
const sx = cx + (x - cx) / scale;
|
||||
const sy = cy + (y - cy) / scale;
|
||||
const [r, g, b, a] = sampleBilinear(img, sx, sy);
|
||||
const di = (y * out.width + x) * 4;
|
||||
out.data[di] = r;
|
||||
out.data[di + 1] = g;
|
||||
out.data[di + 2] = b;
|
||||
out.data[di + 3] = a;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
autoContrast,
|
||||
brightnessContrast,
|
||||
changeHue,
|
||||
extractChannel,
|
||||
gammaCorrection,
|
||||
grayscale,
|
||||
invert,
|
||||
posterize,
|
||||
@@ -10,6 +12,8 @@ import {
|
||||
sepia,
|
||||
setOpacity,
|
||||
swapChannels,
|
||||
temperature,
|
||||
tint,
|
||||
thresholdBlackWhite,
|
||||
twoColors
|
||||
} from './color';
|
||||
@@ -183,3 +187,48 @@ describe('brightnessContrast', () => {
|
||||
expect(out.data[3]).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gammaCorrection', () => {
|
||||
it('гамма 1 — идентичность', () => {
|
||||
const img = makeImage(1, 1, [[64, 128, 192, 255]]);
|
||||
expect([...gammaCorrection(img, 1).data]).toEqual([64, 128, 192, 255]);
|
||||
});
|
||||
it('гамма 2 удваивает яркость (64 → 128)', () => {
|
||||
const out = gammaCorrection(makeImage(1, 1, [[64, 64, 64, 255]]), 2);
|
||||
expect(out.data[0]).toBe(128);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoContrast', () => {
|
||||
it('растягивает диапазон [10..200] до [0..255]', () => {
|
||||
const out = autoContrast(makeImage(2, 1, [
|
||||
[10, 10, 10, 255],
|
||||
[200, 200, 200, 255]
|
||||
]));
|
||||
expect(out.data[0]).toBe(0);
|
||||
expect(out.data[4]).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('temperature', () => {
|
||||
it('положительная — теплее (красный ↑, синий ↓)', () => {
|
||||
const out = temperature(makeImage(1, 1, [[128, 128, 128, 255]]), 50);
|
||||
expect(out.data[0]).toBeGreaterThan(128);
|
||||
expect(out.data[2]).toBeLessThan(128);
|
||||
});
|
||||
it('нулевая температура не меняет', () => {
|
||||
const img = makeImage(1, 1, [[128, 128, 128, 255]]);
|
||||
expect([...temperature(img, 0).data]).toEqual([...img.data]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tint', () => {
|
||||
it('сила 100 на белом даёт чистый цвет', () => {
|
||||
const out = tint(makeImage(1, 1, [[255, 255, 255, 255]]), '#ff0000', 100);
|
||||
expect([...out.data]).toEqual([255, 0, 0, 255]);
|
||||
});
|
||||
it('сила 0 — идентичность', () => {
|
||||
const img = makeImage(1, 1, [[100, 150, 200, 255]]);
|
||||
expect([...tint(img, '#ff0000', 0).data]).toEqual([...img.data]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,3 +223,84 @@ export function rgbToHex(r: number, g: number, b: number): string {
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function gammaCorrection(img: PixelImage, value: number): PixelImage {
|
||||
const g = clamp(value, 0.1, 5);
|
||||
const lut = new Uint8ClampedArray(256);
|
||||
for (let v = 0; v < 256; v++) {
|
||||
lut[v] = 255 * Math.pow(v / 255, 1 / g);
|
||||
}
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
out.data[i] = lut[img.data[i]];
|
||||
out.data[i + 1] = lut[img.data[i + 1]];
|
||||
out.data[i + 2] = lut[img.data[i + 2]];
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function autoContrast(img: PixelImage): PixelImage {
|
||||
const lo = [255, 255, 255];
|
||||
const hi = [0, 0, 0];
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
for (let ch = 0; ch < 3; ch++) {
|
||||
if (img.data[i + ch] < lo[ch]) lo[ch] = img.data[i + ch];
|
||||
if (img.data[i + ch] > hi[ch]) hi[ch] = img.data[i + ch];
|
||||
}
|
||||
}
|
||||
const luts: Uint8ClampedArray[] = [];
|
||||
for (let ch = 0; ch < 3; ch++) {
|
||||
const lut = new Uint8ClampedArray(256);
|
||||
const range = hi[ch] - lo[ch];
|
||||
for (let v = 0; v < 256; v++) {
|
||||
lut[v] = range > 0 ? ((v - lo[ch]) * 255) / range : v;
|
||||
}
|
||||
luts.push(lut);
|
||||
}
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
out.data[i] = luts[0][img.data[i]];
|
||||
out.data[i + 1] = luts[1][img.data[i + 1]];
|
||||
out.data[i + 2] = luts[2][img.data[i + 2]];
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function temperature(img: PixelImage, percent: number): PixelImage {
|
||||
const k = clamp(percent, -100, 100) / 100;
|
||||
const rFactor = 1 + 0.25 * k;
|
||||
const bFactor = 1 - 0.25 * k;
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
out.data[i] = img.data[i] * rFactor;
|
||||
out.data[i + 1] = img.data[i + 1];
|
||||
out.data[i + 2] = img.data[i + 2] * bFactor;
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function tint(
|
||||
img: PixelImage,
|
||||
colorHex: string,
|
||||
strengthPercent: number
|
||||
): PixelImage {
|
||||
const s = clamp(strengthPercent, 0, 100) / 100;
|
||||
const match = /^#([0-9a-f]{6})$/i.exec(colorHex.trim());
|
||||
if (!match) throw new Error(`Некорректный HEX-цвет: "${colorHex}"`);
|
||||
const d = match[1];
|
||||
const tr = parseInt(d.slice(0, 2), 16) / 255;
|
||||
const tg = parseInt(d.slice(2, 4), 16) / 255;
|
||||
const tb = parseInt(d.slice(4, 6), 16) / 255;
|
||||
const factors = [1 + (tr - 1) * s, 1 + (tg - 1) * s, 1 + (tb - 1) * s];
|
||||
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] = img.data[i + ch] * factors[ch];
|
||||
}
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { vignette } from './effects';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
describe('vignette', () => {
|
||||
it('сила 0 — идентичное преобразование', () => {
|
||||
const img = makeImage(3, 3, new Array(9).fill([200, 100, 50, 255]));
|
||||
expect([...vignette(img, 0).data]).toEqual([...img.data]);
|
||||
});
|
||||
|
||||
it('углы темнее центра', () => {
|
||||
const img = makeImage(7, 7, new Array(49).fill([200, 200, 200, 255]));
|
||||
const out = vignette(img, 80);
|
||||
const centerR = out.data[(3 * 7 + 3) * 4];
|
||||
const cornerR = out.data[0];
|
||||
expect(cornerR).toBeLessThan(centerR);
|
||||
expect(centerR).toBeGreaterThan(150);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||
|
||||
export function vignette(img: PixelImage, strengthPercent: number): PixelImage {
|
||||
const strength = Math.min(Math.max(strengthPercent, 0), 100) / 100;
|
||||
if (strength === 0) return clonePixelImage(img);
|
||||
const cx = (img.width - 1) / 2;
|
||||
const cy = (img.height - 1) / 2;
|
||||
const maxDist = Math.sqrt(cx * cx + cy * cy);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
const t = Math.sqrt(dx * dx + dy * dy) / maxDist;
|
||||
const factor = 1 - strength * t * t;
|
||||
const di = (y * img.width + x) * 4;
|
||||
out.data[di] = img.data[di] * factor;
|
||||
out.data[di + 1] = img.data[di + 1] * factor;
|
||||
out.data[di + 2] = img.data[di + 2] * factor;
|
||||
out.data[di + 3] = img.data[di + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -181,6 +181,34 @@ function copyPixel(src: PixelImage, sx: number, sy: number, dst: PixelImage, dx:
|
||||
dst.data[di + 3] = src.data[si + 3];
|
||||
}
|
||||
|
||||
export function sampleBilinear(
|
||||
img: PixelImage,
|
||||
fx: number,
|
||||
fy: number
|
||||
): [number, number, number, number] {
|
||||
const maxX = img.width - 1;
|
||||
const maxY = img.height - 1;
|
||||
const cx = Math.min(Math.max(fx, 0), maxX);
|
||||
const cy = Math.min(Math.max(fy, 0), maxY);
|
||||
const x0 = Math.floor(cx);
|
||||
const y0 = Math.floor(cy);
|
||||
const tx = cx - x0;
|
||||
const ty = cy - y0;
|
||||
const x1 = Math.min(x0 + 1, maxX);
|
||||
const y1 = Math.min(y0 + 1, maxY);
|
||||
const i00 = (y0 * img.width + x0) * 4;
|
||||
const i10 = (y0 * img.width + x1) * 4;
|
||||
const i01 = (y1 * img.width + x0) * 4;
|
||||
const i11 = (y1 * img.width + x1) * 4;
|
||||
const result: [number, number, number, number] = [0, 0, 0, 0];
|
||||
for (let ch = 0; ch < 4; ch++) {
|
||||
const top = (1 - tx) * img.data[i00 + ch] + tx * img.data[i10 + ch];
|
||||
const bottom = (1 - tx) * img.data[i01 + ch] + tx * img.data[i11 + ch];
|
||||
result[ch] = (1 - ty) * top + ty * bottom;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||||
}
|
||||
|
||||
@@ -123,3 +123,44 @@ export function replaceExtension(filename: string, ext: string): string {
|
||||
const base = filename.replace(/\.[^./\\]+$/, '');
|
||||
return `${base}.${ext}`;
|
||||
}
|
||||
|
||||
export async function decodeSvgText(text: string, targetWidth?: number): Promise<PixelImage> {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error('Вставьте разметку SVG');
|
||||
}
|
||||
const blob = new Blob([trimmed], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error('Не удалось загрузить SVG — проверьте разметку'));
|
||||
img.src = url;
|
||||
});
|
||||
const w = targetWidth ?? img.naturalWidth ?? 300;
|
||||
const ratio = img.naturalHeight > 0 ? img.naturalHeight / img.naturalWidth : 1;
|
||||
const h = Math.max(1, Math.round(w * ratio));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) throw new Error('Canvas 2D context недоступен в этом браузере');
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
const imageData = ctx.getImageData(0, 0, w, h);
|
||||
return { width: imageData.width, height: imageData.height, data: imageData.data };
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export async function jpegRoundtrip(img: PixelImage, qualityPercent: number): Promise<PixelImage> {
|
||||
const quality = Math.min(Math.max(Math.trunc(qualityPercent), 1), 100) / 100;
|
||||
const jpegBlob = await encode(img, 'image/jpeg', quality);
|
||||
const bitmap = await createImageBitmap(jpegBlob);
|
||||
try {
|
||||
return await decodeBitmap(bitmap);
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
+130
-1
@@ -28,23 +28,29 @@ import {
|
||||
} from './core/morphology';
|
||||
import { gaussianBlur, sharpen as sharpenImage } from './core/convolution';
|
||||
import { gradientImage, noiseImage, solidImage } from './core/generate';
|
||||
import { rotateFreeImage, skewImage, transformImage, zoomImage } from './core/affine';
|
||||
import { vignette } from './core/effects';
|
||||
import {
|
||||
autoContrast,
|
||||
brightnessContrast,
|
||||
changeHue,
|
||||
extractChannel,
|
||||
gammaCorrection,
|
||||
grayscale,
|
||||
invert,
|
||||
posterize,
|
||||
sepia,
|
||||
setOpacity,
|
||||
swapChannels,
|
||||
temperature,
|
||||
tint,
|
||||
thresholdBlackWhite,
|
||||
twoColors,
|
||||
type ChannelSwapPair,
|
||||
type RgbChannel
|
||||
} from './core/color';
|
||||
import { centerByAlpha, crop, expandCanvas, flip, resize, rotate90, tile } from './core/geometry';
|
||||
import { decodeTextImage, toBase64, toDataUrl, type OutputMime } from './core/io';
|
||||
import { decodeSvgText, decodeTextImage, jpegRoundtrip, toBase64, toDataUrl, type OutputMime } from './core/io';
|
||||
import { hexToPixels, pixelsToHex } from './core/text';
|
||||
import { clonePixelImage, type PixelImage } from './core/types';
|
||||
|
||||
@@ -860,6 +866,129 @@ export const TOOLS: ToolEntry[] = [
|
||||
? 'Да — все пиксели являются оттенками серого.'
|
||||
: 'Нет — найдены цветные пиксели.'
|
||||
},
|
||||
{
|
||||
id: 'skew-png',
|
||||
title: 'Наклонить PNG',
|
||||
description: 'Сдвигает содержимое по горизонтали и вертикали — эффект перспективы.',
|
||||
category: 'geometry',
|
||||
params: [
|
||||
{ id: 'degX', label: 'Наклон по X, °', type: 'slider', min: -80, max: 80, step: 1, default: 0 },
|
||||
{ id: 'degY', label: 'Наклон по Y, °', type: 'slider', min: -80, max: 80, step: 1, default: 0 }
|
||||
],
|
||||
run: (img, p) => skewImage(img, num(p, 'degX'), num(p, 'degY'))
|
||||
},
|
||||
{
|
||||
id: 'rotate-free-png',
|
||||
title: 'Повернуть на произвольный угол',
|
||||
description:
|
||||
'Поворот на любой угол. Холст расширяется под новые габариты, углы остаются прозрачными.',
|
||||
category: 'geometry',
|
||||
params: [
|
||||
{ id: 'angle', label: 'Угол, °', type: 'slider', min: -180, max: 180, step: 1, default: 15 }
|
||||
],
|
||||
run: (img, p) => rotateFreeImage(img, num(p, 'angle'))
|
||||
},
|
||||
{
|
||||
id: 'zoom-png',
|
||||
title: 'Приблизить PNG',
|
||||
description:
|
||||
'Увеличивает содержимое к центру. Холст прежнего размера — края обрезаются.',
|
||||
category: 'geometry',
|
||||
params: [
|
||||
{ id: 'scale', label: 'Масштаб, %', type: 'slider', min: 100, max: 500, step: 10, default: 200 }
|
||||
],
|
||||
run: (img, p) => zoomImage(img, num(p, 'scale'))
|
||||
},
|
||||
{
|
||||
id: 'shift-png',
|
||||
title: 'Сдвинуть PNG',
|
||||
description: 'Перемещает содержимое на заданное смещение по X и Y.',
|
||||
category: 'geometry',
|
||||
params: [
|
||||
{ id: 'offsetX', label: 'Смещение X, px', type: 'number', min: -5000, max: 5000, step: 1, default: 0 },
|
||||
{ id: 'offsetY', label: 'Смещение Y, px', type: 'number', min: -5000, max: 5000, step: 1, default: 0 },
|
||||
{ id: 'color', label: 'Цвет фона', type: 'color', default: '#ffffff' }
|
||||
],
|
||||
run: (img, p) =>
|
||||
transformImage(
|
||||
img,
|
||||
[1, 0, 0, 1, -Math.trunc(num(p, 'offsetX')), -Math.trunc(num(p, 'offsetY'))],
|
||||
img.width,
|
||||
img.height,
|
||||
str(p, 'color')
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'vignette-png',
|
||||
title: 'Виньетка PNG',
|
||||
description: 'Плавно затемняет края изображения, центр не затрагивает.',
|
||||
category: 'filters',
|
||||
params: [
|
||||
{ id: 'strength', label: 'Сила затемнения, %', type: 'slider', min: 0, max: 100, step: 5, default: 50 }
|
||||
],
|
||||
run: (img, p) => vignette(img, num(p, 'strength'))
|
||||
},
|
||||
{
|
||||
id: 'jpeg-artifacts-png',
|
||||
title: 'Артефакты JPEG',
|
||||
description:
|
||||
'Имитирует пережатие в JPEG с низким качеством — видимые квадраты и размытие цветов.',
|
||||
category: 'filters',
|
||||
params: [
|
||||
{ id: 'quality', label: 'Качество JPEG', type: 'slider', min: 1, max: 50, step: 1, default: 10 }
|
||||
],
|
||||
run: (img, p) => jpegRoundtrip(img, num(p, 'quality'))
|
||||
},
|
||||
{
|
||||
id: 'gamma-png',
|
||||
title: 'Гамма-коррекция PNG',
|
||||
description: 'Корректирует яркость средних тонов. <1 темнее, >1 светлее, 1 — без изменений.',
|
||||
category: 'color',
|
||||
params: [
|
||||
{ id: 'value', label: 'Гамма', type: 'slider', min: 0.1, max: 3, step: 0.05, default: 1 }
|
||||
],
|
||||
run: (img, p) => gammaCorrection(img, num(p, 'value'))
|
||||
},
|
||||
{
|
||||
id: 'auto-contrast-png',
|
||||
title: 'Автоконтраст PNG',
|
||||
description: 'Растягивает диапазон каждого канала на весь доступный диапазон яркости.',
|
||||
category: 'color',
|
||||
params: [],
|
||||
run: (img) => autoContrast(img)
|
||||
},
|
||||
{
|
||||
id: 'temperature-png',
|
||||
title: 'Температура PNG',
|
||||
description: 'Положительные значения делают изображение теплее (оранжевее), отрицательные — холоднее (синеватее).',
|
||||
category: 'color',
|
||||
params: [
|
||||
{ id: 'percent', label: 'Температура', type: 'slider', min: -100, max: 100, step: 1, default: 0 }
|
||||
],
|
||||
run: (img, p) => temperature(img, num(p, 'percent'))
|
||||
},
|
||||
{
|
||||
id: 'tint-png',
|
||||
title: 'Тонировать PNG',
|
||||
description: 'Умножает цветовые каналы на выбранный оттенок с заданной силой.',
|
||||
category: 'color',
|
||||
params: [
|
||||
{ id: 'color', label: 'Цвет тонирования', type: 'color', default: '#ffb060' },
|
||||
{ id: 'strength', label: 'Сила, %', type: 'slider', min: 0, max: 100, step: 1, default: 30 }
|
||||
],
|
||||
run: (img, p) => tint(img, str(p, 'color'), num(p, 'strength'))
|
||||
},
|
||||
{
|
||||
id: 'svg-to-png',
|
||||
title: 'SVG в PNG',
|
||||
description:
|
||||
'Dекодирует SVG-разметку в растровое изображение. Вставьте SVG-код слева.',
|
||||
category: 'convert',
|
||||
sourceMode: 'text',
|
||||
params: [{ id: 'width', label: 'Ширина результата, px', type: 'number', min: 1, max: 10000, step: 1, default: 512 }],
|
||||
runFromText: (text, p) => decodeSvgText(text, Math.trunc(num(p, 'width'))),
|
||||
run: (img) => clonePixelImage(img)
|
||||
},
|
||||
{
|
||||
id: 'png-is-transparent',
|
||||
title: 'Проверить: PNG прозрачный?',
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { rotateFreeImage, skewImage, transformImage, zoomImage } from './core/affine';
|
||||
import { vignette } from './core/effects';
|
||||
import { autoContrast, gammaCorrection, temperature, tint } from './core/color';
|
||||
import { makeImage } from './core/test-helpers';
|
||||
|
||||
function solid(w: number, h: number, rgba: [number, number, number, number]) {
|
||||
return makeImage(w, h, new Array(w * h).fill(rgba));
|
||||
}
|
||||
|
||||
function opaqueRatio(img: { data: Uint8ClampedArray }) {
|
||||
let n = 0;
|
||||
for (let i = 3; i < img.data.length; i += 4) if (img.data[i] === 255) n++;
|
||||
return n / (img.data.length / 4);
|
||||
}
|
||||
|
||||
function px(img: { width: number; data: Uint8ClampedArray }, x: number, y: number) {
|
||||
const d = (y * img.width + x) * 4;
|
||||
return [img.data[d], img.data[d + 1], img.data[d + 2], img.data[d + 3]] as const;
|
||||
}
|
||||
|
||||
describe('Смоук §5 wave3', () => {
|
||||
it('1. Наклон X/Y по отдельности и вместе искажает форму без чёрных полос', () => {
|
||||
const img = solid(20, 20, [200, 30, 30, 255]);
|
||||
for (const [dx, dy] of [
|
||||
[15, 0],
|
||||
[0, 15],
|
||||
[12, 12]
|
||||
] as const) {
|
||||
const out = skewImage(img, dx, dy);
|
||||
expect(out.width).toBeGreaterThanOrEqual(img.width);
|
||||
expect(out.height).toBeGreaterThanOrEqual(img.height);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
const [r, g, b] = [out.data[i], out.data[i + 1], out.data[i + 2]];
|
||||
if (out.data[i + 3] > 0) {
|
||||
expect(r).toBeGreaterThan(150);
|
||||
expect(g).toBeLessThan(80);
|
||||
expect(b).toBeLessThan(80);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('2. Поворот 30°: холст вырос, углы прозрачны, центр на месте', () => {
|
||||
const img = solid(100, 100, [255, 0, 0, 255]);
|
||||
const out = rotateFreeImage(img, 30);
|
||||
expect(out.width).toBe(136);
|
||||
expect(out.height).toBe(136);
|
||||
expect(px(out, 0, 0)[3]).toBe(0);
|
||||
expect(px(out, 135, 0)[3]).toBe(0);
|
||||
expect(px(out, 0, 135)[3]).toBe(0);
|
||||
expect(px(out, 135, 135)[3]).toBe(0);
|
||||
expect(px(out, 67, 67)).toEqual([255, 0, 0, 255]);
|
||||
});
|
||||
|
||||
it('3. Зум 200%: холст прежний, центр совпадает, края обрезаны', () => {
|
||||
const grad = makeImage(
|
||||
11,
|
||||
11,
|
||||
Array.from({ length: 121 }, (_, i) => [(i * 2) % 256, i % 11 * 23, 128, 255] as [number, number, number, number])
|
||||
);
|
||||
const out = zoomImage(grad, 200);
|
||||
expect(out.width).toBe(11);
|
||||
expect(out.height).toBe(11);
|
||||
expect([...px(out, 5, 5)]).toEqual([...px(grad, 5, 5)]);
|
||||
});
|
||||
|
||||
it('4. Сдвиг уводит содержимое за край, противоположный край залит фоном', () => {
|
||||
const img = solid(4, 4, [0, 0, 255, 255]);
|
||||
const out = transformImage(img, [1, 0, 0, 1, -2, 0], 4, 4, '#ffffff');
|
||||
expect(px(out, 0, 0)).toEqual([255, 255, 255, 255]);
|
||||
expect(px(out, 3, 0)).toEqual([0, 0, 255, 255]);
|
||||
});
|
||||
|
||||
it('5. Гамма: 1 — без изменений, 0.5 темнее, 2 светлее', () => {
|
||||
const img = solid(3, 3, [128, 128, 128, 255]);
|
||||
const id = gammaCorrection(img, 1);
|
||||
expect([...id.data]).toEqual([...img.data]);
|
||||
const dark = px(gammaCorrection(img, 0.5), 1, 1)[0];
|
||||
const light = px(gammaCorrection(img, 2), 1, 1)[0];
|
||||
expect(dark).toBeLessThan(128);
|
||||
expect(light).toBeGreaterThan(128);
|
||||
});
|
||||
|
||||
it('6. Автоконтраст вытягивает тусклый диапазон', () => {
|
||||
const dim = makeImage(
|
||||
2,
|
||||
2,
|
||||
new Array(4).fill([50, 55, 45, 255])
|
||||
);
|
||||
dim.data[0] = 40;
|
||||
const out = autoContrast(dim);
|
||||
const values = Array.from({ length: 4 }, (_, k) => px(out, k % 2, Math.floor(k / 2))[0]);
|
||||
expect(Math.min(...values)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('7. Температура ±50 теплеет/холодеет заметно', () => {
|
||||
const img = solid(2, 2, [128, 128, 128, 255]);
|
||||
const warm = px(temperature(img, 50), 0, 0);
|
||||
const cool = px(temperature(img, -50), 0, 0);
|
||||
expect(warm[0]).toBeGreaterThan(cool[0]);
|
||||
expect(warm[2]).toBeLessThan(cool[2]);
|
||||
});
|
||||
|
||||
it('8. Тонирование красным: белое окрашивается сильнее серого', () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[255, 255, 255, 255],
|
||||
[128, 128, 128, 255]
|
||||
]);
|
||||
const out = tint(img, '#ff0000', 100);
|
||||
expect(px(out, 0, 0)).toEqual([255, 0, 0, 255]);
|
||||
const gray = px(out, 1, 0);
|
||||
expect(gray[1]).toBe(0);
|
||||
expect(gray[0]).toBeGreaterThan(gray[2]);
|
||||
});
|
||||
|
||||
it('9. Виньетка: сила 0 — идентичность, центр не темнеет, углы темнеют', () => {
|
||||
const img = solid(21, 21, [200, 200, 200, 255]);
|
||||
expect([...vignette(img, 0).data]).toEqual([...img.data]);
|
||||
const out = vignette(img, 60);
|
||||
expect(px(out, 10, 10)).toEqual([200, 200, 200, 255]);
|
||||
expect(px(out, 0, 0)[0]).toBeLessThan(120);
|
||||
});
|
||||
});
|
||||
@@ -99,5 +99,16 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
|
||||
'make-thinner-png': ZoomOut,
|
||||
'harden-alpha-png': Sun,
|
||||
'despeckle-alpha-png': SearchCheck,
|
||||
'close-holes-png': Hammer
|
||||
'close-holes-png': Hammer,
|
||||
'skew-png': Repeat2,
|
||||
'rotate-free-png': RotateCw,
|
||||
'zoom-png': ZoomIn,
|
||||
'shift-png': Crosshair,
|
||||
'vignette-png': Sun,
|
||||
'jpeg-artifacts-png': Layers,
|
||||
'gamma-png': Contrast,
|
||||
'auto-contrast-png': Contrast,
|
||||
'temperature-png': Sun,
|
||||
'tint-png': Droplet,
|
||||
'svg-to-png': FileImage
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user