Compare commits
2
Commits
2b67414e79
...
83d3ce1198
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83d3ce1198 | ||
|
|
501d2dda4e |
@@ -49,12 +49,12 @@ text-to-png (domText без входной картинки), emoji-to-png, plac
|
|||||||
color-spectrum, colorful-random (seed), draw-grid.
|
color-spectrum, colorful-random (seed), draw-grid.
|
||||||
multi-color-gradient — если успеем новый тип параметра «список цветов», иначе перенос.
|
multi-color-gradient — если успеем новый тип параметра «список цветов», иначе перенос.
|
||||||
|
|
||||||
### W8. Цветовые MEDIUM — 4 инструмента, L
|
### W8. Цветовые MEDIUM — ВЫПОЛНЕНА (4 инструмента; median-cut + Floyd–Steinberg/Bayer, custom-palette через text-параметр)
|
||||||
|
|
||||||
Ядро: квантование (median-cut или k-means).
|
Ядро: квантование (median-cut или k-means).
|
||||||
Состав: quantize (k), decrease-color-count (=quantize с пресетами), custom-palette (маппинг на список цветов — нужен тип параметра «список»), dithering (Floyd–Steinberg/Bayer поверх квантования).
|
Состав: quantize (k), decrease-color-count (=quantize с пресетами), custom-palette (маппинг на список цветов — нужен тип параметра «список»), dithering (Floyd–Steinberg/Bayer поверх квантования).
|
||||||
|
|
||||||
### W9. Сжатие — 4–5 инструментов, L, риск
|
### W9. Сжатие — ВЫПОЛНЕНА (3 инструмента; optimize/change-quality осознанно не выделены — покрыты пресетами compress и W8)
|
||||||
|
|
||||||
Без wasm честное управление размером PNG ограничено: реальный рычаг — квантование (W8) + обрезка метаданных + итеративный подбор под целевой KB.
|
Без wasm честное управление размером PNG ограничено: реальный рычаг — квантование (W8) + обрезка метаданных + итеративный подбор под целевой KB.
|
||||||
Состав: compress (пресеты усилий), reduce-to-size (целевой KB, бинарный поиск по k), optimize (re-encode), change-quality/low-quality — переосмыслить как пресеты W8; jpeg-artifacts уже покрывает «испортить».
|
Состав: compress (пресеты усилий), reduce-to-size (целевой KB, бинарный поиск по k), optimize (re-encode), change-quality/low-quality — переосмыслить как пресеты W8; jpeg-artifacts уже покрывает «испортить».
|
||||||
|
|||||||
+8
-7
@@ -64,6 +64,13 @@
|
|||||||
- gamma-png — value
|
- gamma-png — value
|
||||||
- tint-png — color, strength
|
- tint-png — color, strength
|
||||||
|
|
||||||
|
### Квантование и палитры
|
||||||
|
|
||||||
|
- quantize-png — colors (k, median-cut)
|
||||||
|
- decrease-color-count-png — maxColors (пресеты 2…256)
|
||||||
|
- custom-palette-png — colors (hex через запятую, ближайший цвет)
|
||||||
|
- dithering-png — colors (k), pattern (Floyd–Steinberg / Bayer 4×4)
|
||||||
|
|
||||||
### Разложение каналов
|
### Разложение каналов
|
||||||
|
|
||||||
- png-to-hsl / png-to-hsv / png-to-hsi — component (h/s/l и т.п.), display (gray | space-as-rgb)
|
- png-to-hsl / png-to-hsv / png-to-hsi — component (h/s/l и т.п.), display (gray | space-as-rgb)
|
||||||
@@ -172,13 +179,7 @@
|
|||||||
- sort-pixels — blockSize, ключ (яркость/канал), направление (MEDIUM)
|
- sort-pixels — blockSize, ключ (яркость/канал), направление (MEDIUM)
|
||||||
- slow-reveal / fade-in / fade-out / disappear — анимационные (→ идеи)
|
- slow-reveal / fade-in / fade-out / disappear — анимационные (→ идеи)
|
||||||
|
|
||||||
### Сжатие и качество (5, все MEDIUM)
|
### Сжатие и качество — реализовано (см. Конвертация: compress, reduce-to-size; jpeg-artifacts; W8 quantize)
|
||||||
|
|
||||||
- compress-png — уровень усилий
|
|
||||||
- reduce-file-size — целевой размер KB (итеративный поиск)
|
|
||||||
- optimize-png — пресеты
|
|
||||||
- change-quality — честная семантика для lossless (см. план-гапы §риск)
|
|
||||||
- low-quality-png — частично покрыт jpeg-artifacts; остаток = сильный quantize
|
|
||||||
|
|
||||||
### Генераторы — остаток
|
### Генераторы — остаток
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { COMPRESSION_LEVELS, findMaxColorsWithin } from './compress';
|
||||||
|
|
||||||
|
describe('COMPRESSION_LEVELS', () => {
|
||||||
|
it('пресеты упорядочены по убыванию цветов', () => {
|
||||||
|
const values = Object.values(COMPRESSION_LEVELS);
|
||||||
|
for (let i = 1; i < values.length; i++) expect(values[i - 1]).toBeGreaterThan(values[i]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findMaxColorsWithin', () => {
|
||||||
|
const sizeOf = (k: number) => 1000 + k * 100; // размер растёт с k
|
||||||
|
|
||||||
|
it('подбирает максимум k, укладывающийся в цель', async () => {
|
||||||
|
// target 5200 → подходит k≤42 → ожидаем 42 при maxK≥42
|
||||||
|
const k = await findMaxColorsWithin(5200, 256, async (kk) => sizeOf(kk));
|
||||||
|
expect(k).toBeGreaterThanOrEqual(40);
|
||||||
|
expect(sizeOf(k)).toBeLessThanOrEqual(5200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('цель достигается даже на минимуме — возвращает 2', async () => {
|
||||||
|
const k = await findMaxColorsWithin(50, 256, async (kk) => sizeOf(kk));
|
||||||
|
expect(k).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodeSize null трактуется как провал', async () => {
|
||||||
|
const k = await findMaxColorsWithin(999999, 16, async () => null);
|
||||||
|
expect(k).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('бинарный поиск сходится быстрее полного перебора', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
await findMaxColorsWithin(30000, 256, async (kk) => {
|
||||||
|
calls++;
|
||||||
|
return sizeOf(kk);
|
||||||
|
});
|
||||||
|
expect(calls).toBeLessThanOrEqual(12); // log2(254)+probe(2)
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/** Пресеты сжатия: уровень → число цветов квантования. */
|
||||||
|
export const COMPRESSION_LEVELS = {
|
||||||
|
light: 192,
|
||||||
|
balanced: 96,
|
||||||
|
strong: 44,
|
||||||
|
extreme: 16
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CompressionLevel = keyof typeof COMPRESSION_LEVELS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Бинарный поиск наибольшего k ∈ [2..maxK], при котором закодированный размер
|
||||||
|
* укладывается в targetBytes. Если даже k=2 не влезает — возвращается 2 (best effort).
|
||||||
|
* encodeSize может вернуть null (ошибка кодирования) — трактуется как «не влезло».
|
||||||
|
*/
|
||||||
|
export async function findMaxColorsWithin(
|
||||||
|
targetBytes: number,
|
||||||
|
maxK: number,
|
||||||
|
encodeSize: (k: number) => Promise<number | null>
|
||||||
|
): Promise<number> {
|
||||||
|
const hi = Math.max(2, Math.round(maxK));
|
||||||
|
let ok = 2;
|
||||||
|
let fitsAtTwo = false;
|
||||||
|
const probe = async (k: number): Promise<boolean> => {
|
||||||
|
const size = await encodeSize(Math.max(2, Math.min(hi, k)));
|
||||||
|
if (size === null) return false;
|
||||||
|
if (size <= targetBytes) {
|
||||||
|
ok = k;
|
||||||
|
fitsAtTwo = fitsAtTwo || k === 2;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!(await probe(2))) return 2;
|
||||||
|
let lo = 2;
|
||||||
|
let hiK = hi;
|
||||||
|
while (hiK - lo > 1) {
|
||||||
|
const mid = Math.floor((lo + hiK) / 2);
|
||||||
|
if (await probe(mid)) lo = mid;
|
||||||
|
else hiK = mid;
|
||||||
|
}
|
||||||
|
await probe(lo);
|
||||||
|
void fitsAtTwo;
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ditherImage, mapToNearest, medianCutPalette, quantizeImage } from './quantize';
|
||||||
|
import { makeImage } from './test-helpers';
|
||||||
|
|
||||||
|
describe('medianCutPalette', () => {
|
||||||
|
it('два явных кластера при k=2 дают сами цвета', () => {
|
||||||
|
const img = makeImage(4, 1, [
|
||||||
|
[0, 0, 0, 255],
|
||||||
|
[0, 0, 0, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[255, 255, 255, 255]
|
||||||
|
]);
|
||||||
|
const palette = medianCutPalette(img, 2);
|
||||||
|
expect(palette).toHaveLength(2);
|
||||||
|
const hexes = palette.map((c) => `${c.r},${c.g},${c.b}`).sort();
|
||||||
|
expect(hexes).toEqual(['0,0,0', '255,255,255']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустое изображение даёт чёрную заглушку', () => {
|
||||||
|
const empty = makeImage(2, 2, new Array(4).fill([0, 0, 0, 0]));
|
||||||
|
expect(medianCutPalette(empty, 4)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('quantizeImage / ditherImage', () => {
|
||||||
|
it('квантование укладывает пиксели в палитру, прозрачность сохраняется', () => {
|
||||||
|
const img = makeImage(3, 1, [
|
||||||
|
[10, 10, 10, 255],
|
||||||
|
[250, 250, 250, 255],
|
||||||
|
[0, 0, 0, 0]
|
||||||
|
]);
|
||||||
|
const { image, palette } = quantizeImage(img, 2);
|
||||||
|
expect(palette.length).toBeLessThanOrEqual(2);
|
||||||
|
expect(image.data[(2) * 4 + 3]).toBe(0); // прозрачный остался
|
||||||
|
for (let x = 0; x < 2; x++) {
|
||||||
|
const i = x * 4;
|
||||||
|
const matched = palette.some((hex) => {
|
||||||
|
const c = parseInt(hex.slice(1, 3), 16);
|
||||||
|
return Math.abs(image.data[i] - c) <= 1;
|
||||||
|
});
|
||||||
|
expect(matched).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('floyd-steinberg расщепляет серый 50% на чёрное и белое', () => {
|
||||||
|
const gray = makeImage(16, 16, new Array(256).fill([128, 128, 128, 255]));
|
||||||
|
const out = ditherImage(gray, 2, 'floyd-steinberg', ['#000000', '#ffffff']);
|
||||||
|
let hasDark = false;
|
||||||
|
let hasLight = false;
|
||||||
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
|
if (out.data[i] < 60) hasDark = true;
|
||||||
|
if (out.data[i] > 195) hasLight = true;
|
||||||
|
}
|
||||||
|
expect(hasDark).toBe(true);
|
||||||
|
expect(hasLight).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bayer детерминирован', () => {
|
||||||
|
const gray = makeImage(8, 8, new Array(64).fill([128, 128, 128, 255]));
|
||||||
|
const a = ditherImage(gray, 2, 'bayer');
|
||||||
|
const b = ditherImage(gray, 2, 'bayer');
|
||||||
|
expect([...a.data]).toEqual([...b.data]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('полностью прозрачное изображение не падает', () => {
|
||||||
|
const empty = makeImage(2, 2, new Array(4).fill([0, 0, 0, 0]));
|
||||||
|
const out = ditherImage(empty, 4, 'bayer');
|
||||||
|
expect(out.data[3]).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapToNearest', () => {
|
||||||
|
it('маппинг на ближайший из списка', () => {
|
||||||
|
const img = makeImage(2, 1, [
|
||||||
|
[10, 10, 10, 255],
|
||||||
|
[240, 240, 240, 255]
|
||||||
|
]);
|
||||||
|
const out = mapToNearest(img, ['#000000', '#ffffff']);
|
||||||
|
expect(out.data[0]).toBe(0);
|
||||||
|
expect(out.data[4]).toBe(255);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import type { PixelImage } from './types';
|
||||||
|
import { createPixelImage } from './types';
|
||||||
|
import { rgbToHex } from './palette';
|
||||||
|
import { hexToRgb } from './palette';
|
||||||
|
|
||||||
|
type Rgb = { r: number; g: number; b: number };
|
||||||
|
|
||||||
|
function dist2(a: Rgb, b: Rgb): number {
|
||||||
|
const dr = a.r - b.r;
|
||||||
|
const dg = a.g - b.g;
|
||||||
|
const db = a.b - b.b;
|
||||||
|
return dr * dr + dg * dg + db * db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestIndex(palette: Rgb[], r: number, g: number, b: number): number {
|
||||||
|
let best = 0;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (let i = 0; i < palette.length; i++) {
|
||||||
|
const d = dist2(palette[i], { r, g, b });
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Median-cut: делит корзину с наибольшим разбросом по самому широкому каналу до k корзин. */
|
||||||
|
export function medianCutPalette(img: PixelImage, k: number): Rgb[] {
|
||||||
|
const maxColors = Math.max(2, Math.min(64, Math.round(k)));
|
||||||
|
const pixels: Rgb[] = [];
|
||||||
|
const total = img.width * img.height;
|
||||||
|
const step = Math.max(1, Math.floor(total / 32000));
|
||||||
|
for (let p = 0; p < total; p += step) {
|
||||||
|
const i = p * 4;
|
||||||
|
if (img.data[i + 3] === 0) continue;
|
||||||
|
pixels.push({ r: img.data[i], g: img.data[i + 1], b: img.data[i + 2] });
|
||||||
|
}
|
||||||
|
if (pixels.length === 0) return [{ r: 0, g: 0, b: 0 }];
|
||||||
|
|
||||||
|
type Bucket = { px: Rgb[]; min: Rgb; max: Rgb };
|
||||||
|
const makeBucket = (list: Rgb[]): Bucket => {
|
||||||
|
const min = { r: 255, g: 255, b: 255 };
|
||||||
|
const max = { r: 0, g: 0, b: 0 };
|
||||||
|
for (const c of list) {
|
||||||
|
if (c.r < min.r) min.r = c.r;
|
||||||
|
if (c.g < min.g) min.g = c.g;
|
||||||
|
if (c.b < min.b) min.b = c.b;
|
||||||
|
if (c.r > max.r) max.r = c.r;
|
||||||
|
if (c.g > max.g) max.g = c.g;
|
||||||
|
if (c.b > max.b) max.b = c.b;
|
||||||
|
}
|
||||||
|
return { px: list, min, max };
|
||||||
|
};
|
||||||
|
|
||||||
|
let buckets: Bucket[] = [makeBucket(pixels)];
|
||||||
|
while (buckets.length < maxColors) {
|
||||||
|
let targetIdx = -1;
|
||||||
|
let targetScore = -1;
|
||||||
|
buckets.forEach((bucket, idx) => {
|
||||||
|
if (bucket.px.length < 2) return;
|
||||||
|
const score =
|
||||||
|
(bucket.max.r - bucket.min.r) *
|
||||||
|
(bucket.max.g - bucket.min.g) *
|
||||||
|
(bucket.max.b - bucket.min.b) *
|
||||||
|
bucket.px.length;
|
||||||
|
if (score > targetScore) {
|
||||||
|
targetScore = score;
|
||||||
|
targetIdx = idx;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (targetIdx < 0 || targetScore <= 0) break;
|
||||||
|
|
||||||
|
const bucket = buckets[targetIdx];
|
||||||
|
const ranges = [
|
||||||
|
{ ch: 'r' as const, range: bucket.max.r - bucket.min.r },
|
||||||
|
{ ch: 'g' as const, range: bucket.max.g - bucket.min.g },
|
||||||
|
{ ch: 'b' as const, range: bucket.max.b - bucket.min.b }
|
||||||
|
].sort((a, b) => b.range - a.range);
|
||||||
|
const widest = ranges[0].ch;
|
||||||
|
bucket.px.sort((a, b) => a[widest] - b[widest]);
|
||||||
|
const mid = Math.floor(bucket.px.length / 2);
|
||||||
|
buckets = [
|
||||||
|
...buckets.slice(0, targetIdx),
|
||||||
|
makeBucket(bucket.px.slice(0, mid)),
|
||||||
|
makeBucket(bucket.px.slice(mid)),
|
||||||
|
...buckets.slice(targetIdx + 1)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets
|
||||||
|
.filter((b) => b.px.length > 0)
|
||||||
|
.map((b) => ({
|
||||||
|
r: Math.round(b.px.reduce((s, c) => s + c.r, 0) / b.px.length),
|
||||||
|
g: Math.round(b.px.reduce((s, c) => s + c.g, 0) / b.px.length),
|
||||||
|
b: Math.round(b.px.reduce((s, c) => s + c.b, 0) / b.px.length)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuantizeResult {
|
||||||
|
image: PixelImage;
|
||||||
|
palette: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Приводит изображение к k цветам: median-cut + ближайший цвет палитры. Прозрачные пиксели не трогаются. */
|
||||||
|
export function quantizeImage(img: PixelImage, k: number): QuantizeResult {
|
||||||
|
const palette = medianCutPalette(img, k);
|
||||||
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
for (let i = 0; i < img.data.length; i += 4) {
|
||||||
|
out.data[i + 3] = img.data[i + 3];
|
||||||
|
if (img.data[i + 3] === 0) continue;
|
||||||
|
const chosen = nearestIndex(palette, img.data[i], img.data[i + 1], img.data[i + 2]);
|
||||||
|
out.data[i] = palette[chosen].r;
|
||||||
|
out.data[i + 1] = palette[chosen].g;
|
||||||
|
out.data[i + 2] = palette[chosen].b;
|
||||||
|
}
|
||||||
|
return { image: out, palette: palette.map(rgbToHex) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Маппинг каждого пикселя на ближайший цвет пользовательского списка. */
|
||||||
|
export function mapToNearest(img: PixelImage, paletteHexes: string[]): PixelImage {
|
||||||
|
const palette = paletteHexes.map(hexToRgb);
|
||||||
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
for (let i = 0; i < img.data.length; i += 4) {
|
||||||
|
out.data[i + 3] = img.data[i + 3];
|
||||||
|
if (img.data[i + 3] === 0) continue;
|
||||||
|
const chosen = nearestIndex(palette, img.data[i], img.data[i + 1], img.data[i + 2]);
|
||||||
|
out.data[i] = palette[chosen].r;
|
||||||
|
out.data[i + 1] = palette[chosen].g;
|
||||||
|
out.data[i + 2] = palette[chosen].b;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAYER_4 = [
|
||||||
|
[0, 8, 2, 10],
|
||||||
|
[12, 4, 14, 6],
|
||||||
|
[3, 11, 1, 9],
|
||||||
|
[15, 7, 13, 5]
|
||||||
|
];
|
||||||
|
|
||||||
|
export type DitherPattern = 'floyd-steinberg' | 'bayer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дизеринг к палитре из k цветов (median-cut) или к явно заданному списку hex.
|
||||||
|
* floyd-steinberg — распространение ошибки; bayer — упорядоченный 4×4.
|
||||||
|
*/
|
||||||
|
export function ditherImage(
|
||||||
|
img: PixelImage,
|
||||||
|
k: number,
|
||||||
|
pattern: DitherPattern,
|
||||||
|
forcedPaletteHexes?: string[]
|
||||||
|
): PixelImage {
|
||||||
|
const palette = forcedPaletteHexes
|
||||||
|
? forcedPaletteHexes.map(hexToRgb)
|
||||||
|
: medianCutPalette(img, k);
|
||||||
|
const total = img.width * img.height;
|
||||||
|
const buf = new Float32Array(total * 3);
|
||||||
|
for (let p = 0; p < total; p++) {
|
||||||
|
buf[p * 3] = img.data[p * 4];
|
||||||
|
buf[p * 3 + 1] = img.data[p * 4 + 1];
|
||||||
|
buf[p * 3 + 2] = img.data[p * 4 + 2];
|
||||||
|
}
|
||||||
|
|
||||||
|
const spread = 255 / Math.cbrt(Math.max(2, palette.length));
|
||||||
|
const out = createPixelImage(img.width, img.height);
|
||||||
|
|
||||||
|
for (let y = 0; y < img.height; y++) {
|
||||||
|
for (let x = 0; x < img.width; x++) {
|
||||||
|
const p = y * img.width + x;
|
||||||
|
const di = p * 4;
|
||||||
|
const alpha = img.data[di + 3];
|
||||||
|
out.data[di + 3] = alpha;
|
||||||
|
if (alpha === 0) continue;
|
||||||
|
|
||||||
|
let r = buf[p * 3];
|
||||||
|
let g = buf[p * 3 + 1];
|
||||||
|
let b = buf[p * 3 + 2];
|
||||||
|
|
||||||
|
if (pattern === 'bayer') {
|
||||||
|
const offset = ((BAYER_4[y % 4][x % 4] + 0.5) / 16 - 0.5) * spread;
|
||||||
|
r += offset;
|
||||||
|
g += offset;
|
||||||
|
b += offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosen = nearestIndex(palette, r, g, b);
|
||||||
|
out.data[di] = palette[chosen].r;
|
||||||
|
out.data[di + 1] = palette[chosen].g;
|
||||||
|
out.data[di + 2] = palette[chosen].b;
|
||||||
|
|
||||||
|
if (pattern !== 'floyd-steinberg') continue;
|
||||||
|
const er = r - palette[chosen].r;
|
||||||
|
const eg = g - palette[chosen].g;
|
||||||
|
const eb = b - palette[chosen].b;
|
||||||
|
const push = (nx: number, ny: number, factor: number) => {
|
||||||
|
if (nx < 0 || ny < 0 || nx >= img.width || ny >= img.height) return;
|
||||||
|
const np = ny * img.width + nx;
|
||||||
|
buf[np * 3] += er * factor;
|
||||||
|
buf[np * 3 + 1] += eg * factor;
|
||||||
|
buf[np * 3 + 2] += eb * factor;
|
||||||
|
};
|
||||||
|
push(x + 1, y, 7 / 16);
|
||||||
|
push(x - 1, y + 1, 3 / 16);
|
||||||
|
push(x, y + 1, 5 / 16);
|
||||||
|
push(x + 1, y + 1, 1 / 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -148,6 +148,11 @@ export const en: Dict = {
|
|||||||
sizePositive: 'Dimensions must be positive and finite'
|
sizePositive: 'Dimensions must be positive and finite'
|
||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
|
'png-file-size': {
|
||||||
|
results: {
|
||||||
|
line: 'PNG size: {kb} KB'
|
||||||
|
}
|
||||||
|
},
|
||||||
'verify-is-png': {
|
'verify-is-png': {
|
||||||
results: {
|
results: {
|
||||||
verifyYes: 'Yes — this is a valid PNG signature.',
|
verifyYes: 'Yes — this is a valid PNG signature.',
|
||||||
|
|||||||
@@ -853,6 +853,62 @@ export const ru: Dict = {
|
|||||||
transparentBg: 'Прозрачный фон'
|
transparentBg: 'Прозрачный фон'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'quantize-png': {
|
||||||
|
title: 'Квантовать PNG',
|
||||||
|
description:
|
||||||
|
'Уменьшает изображение до k цветов через median-cut палитру. Прозрачные пиксели сохраняются.',
|
||||||
|
params: { colors: 'Цветов (k)' }
|
||||||
|
},
|
||||||
|
'decrease-color-count-png': {
|
||||||
|
title: 'Уменьшить число цветов PNG',
|
||||||
|
description:
|
||||||
|
'Тот же движок median-cut с фиксированными пресетами степеней двойки — быстрый спуск до 2–256 цветов.',
|
||||||
|
params: { maxColors: 'Максимум цветов' },
|
||||||
|
options: {
|
||||||
|
maxColors: { '2': '2', '4': '4', '8': '8', '16': '16', '32': '32', '64': '64', '128': '128', '256': '256' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'custom-palette-png': {
|
||||||
|
title: 'Своя палитра PNG',
|
||||||
|
description: 'Сопоставляет каждый пиксель с ближайшим цветом из вашего списка hex через запятую.',
|
||||||
|
params: { colors: 'Палитра (hex через запятую)' }
|
||||||
|
},
|
||||||
|
'dithering-png': {
|
||||||
|
title: 'Дизеринг PNG',
|
||||||
|
description:
|
||||||
|
'Применяет распространение ошибки Флойда–Стейнберга или упорядоченный Байер при сведении к k цветам.',
|
||||||
|
params: { colors: 'Цветов (k)', pattern: 'Узор' },
|
||||||
|
options: {
|
||||||
|
pattern: { 'floyd-steinberg': 'Флойд–Стейнберг', bayer: 'Байер 4×4' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'compress-png': {
|
||||||
|
title: 'Сжать PNG',
|
||||||
|
description:
|
||||||
|
'Уменьшает размер PNG сокращением палитры до выбранного уровня. Честный компромисс: меньше цветов — меньше файл.',
|
||||||
|
params: { level: 'Уровень сжатия' },
|
||||||
|
options: {
|
||||||
|
level: {
|
||||||
|
light: 'Лёгкий (192 цвета)',
|
||||||
|
balanced: 'Сбалансированный (96 цветов)',
|
||||||
|
strong: 'Сильный (44 цвета)',
|
||||||
|
extreme: 'Экстремальный (16 цветов)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'reduce-to-size-png': {
|
||||||
|
title: 'Вписать PNG в размер',
|
||||||
|
description:
|
||||||
|
'Бинарным поиском подбирает размер палитры, чтобы закодированный PNG уложился в целевое количество КБ. Если не влезает даже при 2 цветах — вернёт двухцветный вариант.',
|
||||||
|
params: { targetKB: 'Целевой размер, КБ', maxColors: 'Максимум цветов для перебора' }
|
||||||
|
},
|
||||||
|
'png-file-size': {
|
||||||
|
title: 'Размер файла PNG',
|
||||||
|
description: 'Кодирует изображение в PNG и показывает итоговый размер файла.',
|
||||||
|
results: {
|
||||||
|
line: 'Размер PNG: {kb} КБ'
|
||||||
|
}
|
||||||
|
},
|
||||||
'show-transparent-png': {
|
'show-transparent-png': {
|
||||||
title: 'Показать прозрачные области PNG',
|
title: 'Показать прозрачные области PNG',
|
||||||
description:
|
description:
|
||||||
|
|||||||
+153
-3
@@ -71,6 +71,9 @@ import {
|
|||||||
stripDataUri
|
stripDataUri
|
||||||
} from './core/textio';
|
} from './core/textio';
|
||||||
import { colorSpectrum, drawGrid, randomColorBlocks } from './core/gen-tools';
|
import { colorSpectrum, drawGrid, randomColorBlocks } from './core/gen-tools';
|
||||||
|
import { COMPRESSION_LEVELS, findMaxColorsWithin, type CompressionLevel } from './core/compress';
|
||||||
|
import { encode } from './core/io';
|
||||||
|
import { t } from './i18n/t';
|
||||||
import { formatStamp } from './core/datefmt';
|
import { formatStamp } from './core/datefmt';
|
||||||
import type { Position9 } from './core/textdraw';
|
import type { Position9 } from './core/textdraw';
|
||||||
import {
|
import {
|
||||||
@@ -97,6 +100,11 @@ import {
|
|||||||
renderPredicateMask
|
renderPredicateMask
|
||||||
} from './core/masks';
|
} from './core/masks';
|
||||||
import { renderSpace, SPACES, type SpaceId } from './core/channels';
|
import { renderSpace, SPACES, type SpaceId } from './core/channels';
|
||||||
|
import {
|
||||||
|
ditherImage,
|
||||||
|
mapToNearest,
|
||||||
|
quantizeImage
|
||||||
|
} from './core/quantize';
|
||||||
import {
|
import {
|
||||||
boxTest,
|
boxTest,
|
||||||
circleTest,
|
circleTest,
|
||||||
@@ -2284,9 +2292,151 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
{ id: 'color', label: 'Tint color', type: 'color', default: '#ffb060' },
|
{ id: 'color', label: 'Tint color', type: 'color', default: '#ffb060' },
|
||||||
{ id: 'strength', label: 'Strength, %', type: 'slider', min: 0, max: 100, step: 1, default: 30 }
|
{ id: 'strength', label: 'Strength, %', type: 'slider', min: 0, max: 100, step: 1, default: 30 }
|
||||||
],
|
],
|
||||||
run: (img, p) => tint(img, str(p, 'color'), num(p, 'strength'))
|
run: (img, p) => tint(img, str(p, 'color'), num(p, 'strength'))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'quantize-png',
|
||||||
|
title: 'Quantize PNG',
|
||||||
|
description:
|
||||||
|
'Reduces the image to k colors via median-cut palette. Transparent pixels are preserved.',
|
||||||
|
category: 'color',
|
||||||
|
params: [
|
||||||
|
{ id: 'colors', label: 'Colors (k)', type: 'slider', min: 2, max: 64, step: 1, default: 16 }
|
||||||
|
],
|
||||||
|
run: (img, p) => quantizeImage(img, num(p, 'colors')).image
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'decrease-color-count-png',
|
||||||
|
title: 'Decrease Color Count PNG',
|
||||||
|
description:
|
||||||
|
'Same median-cut engine with fixed power-of-two presets — quick way to drop to 2–256 colors.',
|
||||||
|
category: 'color',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
id: 'maxColors',
|
||||||
|
label: 'Max colors',
|
||||||
|
type: 'select',
|
||||||
|
default: '16',
|
||||||
|
options: [
|
||||||
|
{ value: '2', label: '2' },
|
||||||
|
{ value: '4', label: '4' },
|
||||||
|
{ value: '8', label: '8' },
|
||||||
|
{ value: '16', label: '16' },
|
||||||
|
{ value: '32', label: '32' },
|
||||||
|
{ value: '64', label: '64' },
|
||||||
|
{ value: '128', label: '128' },
|
||||||
|
{ value: '256', label: '256' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
run: (img, p) => quantizeImage(img, num(p, 'maxColors')).image
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'custom-palette-png',
|
||||||
|
title: 'Custom Palette PNG',
|
||||||
|
description:
|
||||||
|
'Maps every pixel to the nearest color from your comma-separated hex list.',
|
||||||
|
category: 'color',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
id: 'colors',
|
||||||
|
label: 'Palette (comma-separated hex)',
|
||||||
|
type: 'text',
|
||||||
|
default: '#000000,#ffffff'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
run: (img, p) => mapToNearest(img, parseHexList(str(p, 'colors')))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dithering-png',
|
||||||
|
title: 'Dithering PNG',
|
||||||
|
description:
|
||||||
|
'Applies Floyd–Steinberg error diffusion or ordered Bayer dithering while reducing to k colors.',
|
||||||
|
category: 'color',
|
||||||
|
params: [
|
||||||
|
{ id: 'colors', label: 'Colors (k)', type: 'slider', min: 2, max: 16, step: 1, default: 4 },
|
||||||
|
{
|
||||||
|
id: 'pattern',
|
||||||
|
label: 'Pattern',
|
||||||
|
type: 'select',
|
||||||
|
default: 'floyd-steinberg',
|
||||||
|
options: [
|
||||||
|
{ value: 'floyd-steinberg', label: 'Floyd–Steinberg' },
|
||||||
|
{ value: 'bayer', label: 'Bayer 4×4' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
run: (img, p) =>
|
||||||
|
ditherImage(
|
||||||
|
img,
|
||||||
|
num(p, 'colors'),
|
||||||
|
str(p, 'pattern') === 'bayer' ? 'bayer' : 'floyd-steinberg'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'compress-png',
|
||||||
|
title: 'Compress PNG',
|
||||||
|
description:
|
||||||
|
'Shrinks the PNG by reducing its palette to a preset level. Honest trade-off: fewer colors = smaller file.',
|
||||||
|
category: 'convert',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
id: 'level',
|
||||||
|
label: 'Compression level',
|
||||||
|
type: 'select',
|
||||||
|
default: 'balanced',
|
||||||
|
options: [
|
||||||
|
{ value: 'light', label: 'Light (192 colors)' },
|
||||||
|
{ value: 'balanced', label: 'Balanced (96 colors)' },
|
||||||
|
{ value: 'strong', label: 'Strong (44 colors)' },
|
||||||
|
{ value: 'extreme', label: 'Extreme (16 colors)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
run: (img, p) => {
|
||||||
|
const level = str(p, 'level') as CompressionLevel;
|
||||||
|
const k = COMPRESSION_LEVELS[level] ?? COMPRESSION_LEVELS.balanced;
|
||||||
|
return quantizeImage(img, k).image;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reduce-to-size-png',
|
||||||
|
title: 'Reduce PNG to Size',
|
||||||
|
description:
|
||||||
|
'Binary-searches the palette size until the encoded PNG fits the target KB. Best effort: if even 2 colors exceed the target, returns the 2-color version.',
|
||||||
|
category: 'convert',
|
||||||
|
domOnly: true,
|
||||||
|
params: [
|
||||||
|
{ id: 'targetKB', label: 'Target size, KB', type: 'slider', min: 5, max: 2000, step: 5, default: 100 },
|
||||||
|
{ id: 'maxColors', label: 'Max colors to try', type: 'slider', min: 2, max: 256, step: 1, default: 256 }
|
||||||
|
],
|
||||||
|
run: async (img, p) => {
|
||||||
|
const targetBytes = num(p, 'targetKB') * 1024;
|
||||||
|
const maxK = num(p, 'maxColors');
|
||||||
|
const encodeSize = async (k: number): Promise<number | null> => {
|
||||||
|
const blob = await encode(quantizeImage(img, k).image, 'image/png');
|
||||||
|
return blob.size;
|
||||||
|
};
|
||||||
|
const k = await findMaxColorsWithin(targetBytes, maxK, encodeSize);
|
||||||
|
return quantizeImage(img, k).image;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'png-file-size',
|
||||||
|
title: 'PNG File Size',
|
||||||
|
description: 'Encodes the image as PNG and reports the resulting file size.',
|
||||||
|
category: 'analyze',
|
||||||
|
domOnly: true,
|
||||||
|
params: [],
|
||||||
|
resultType: 'text',
|
||||||
|
toText: async (img) => {
|
||||||
|
const blob = await encode(img, 'image/png');
|
||||||
|
const kb = blob.size / 1024;
|
||||||
|
const kbText = kb >= 100 ? Math.round(kb).toString() : kb.toFixed(1);
|
||||||
|
return t('tools.png-file-size.results.line', { kb: kbText });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
id: 'svg-to-png',
|
id: 'svg-to-png',
|
||||||
title: 'SVG to PNG',
|
title: 'SVG to PNG',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
Layers,
|
Layers,
|
||||||
Link2,
|
Link2,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
Moon,
|
Moon,
|
||||||
PaintBucket,
|
PaintBucket,
|
||||||
Palette,
|
Palette,
|
||||||
@@ -117,6 +118,10 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
|
|||||||
'dark-pixel-mask-png': Moon,
|
'dark-pixel-mask-png': Moon,
|
||||||
'unique-color-mask-png': Dices,
|
'unique-color-mask-png': Dices,
|
||||||
'extract-color-from-png': Focus,
|
'extract-color-from-png': Focus,
|
||||||
|
'quantize-png': Contrast,
|
||||||
|
'decrease-color-count-png': Scaling,
|
||||||
|
'custom-palette-png': Palette,
|
||||||
|
'dithering-png': Dices,
|
||||||
'feather-edges-png': Droplets,
|
'feather-edges-png': Droplets,
|
||||||
'clean-edges-png': Scissors,
|
'clean-edges-png': Scissors,
|
||||||
'pixelate-png': Grid3x3,
|
'pixelate-png': Grid3x3,
|
||||||
@@ -128,6 +133,9 @@ export const TOOL_ICONS: Record<string, typeof AppWindow> = {
|
|||||||
'change-aspect-ratio-png': Scaling,
|
'change-aspect-ratio-png': Scaling,
|
||||||
'swap-orientation-png': RotateCw,
|
'swap-orientation-png': RotateCw,
|
||||||
'symmetric-copy-png': FlipHorizontal2,
|
'symmetric-copy-png': FlipHorizontal2,
|
||||||
|
'compress-png': Minimize2,
|
||||||
|
'reduce-to-size-png': Scaling,
|
||||||
|
'png-file-size': Info,
|
||||||
'png-to-bytes': Binary,
|
'png-to-bytes': Binary,
|
||||||
'bytes-to-png': Binary,
|
'bytes-to-png': Binary,
|
||||||
'png-to-rgb-values': Hash,
|
'png-to-rgb-values': Hash,
|
||||||
|
|||||||
Reference in New Issue
Block a user