feat: add compression tools
This commit is contained in:
@@ -54,7 +54,7 @@ multi-color-gradient — если успеем новый тип парамет
|
|||||||
Ядро: квантование (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 уже покрывает «испортить».
|
||||||
|
|||||||
+1
-7
@@ -179,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;
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
|||||||
@@ -882,6 +882,33 @@ export const ru: Dict = {
|
|||||||
pattern: { 'floyd-steinberg': 'Флойд–Стейнберг', bayer: 'Байер 4×4' }
|
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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -2370,6 +2373,69 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
str(p, 'pattern') === 'bayer' ? 'bayer' : 'floyd-steinberg'
|
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',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
Layers,
|
Layers,
|
||||||
Link2,
|
Link2,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
Moon,
|
Moon,
|
||||||
PaintBucket,
|
PaintBucket,
|
||||||
Palette,
|
Palette,
|
||||||
@@ -132,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