diff --git a/docs/plan-gap-waves.md b/docs/plan-gap-waves.md index 1c25a49..45235ca 100644 --- a/docs/plan-gap-waves.md +++ b/docs/plan-gap-waves.md @@ -54,7 +54,7 @@ multi-color-gradient — если успеем новый тип парамет Ядро: квантование (median-cut или k-means). Состав: 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. Состав: compress (пресеты усилий), reduce-to-size (целевой KB, бинарный поиск по k), optimize (re-encode), change-quality/low-quality — переосмыслить как пресеты W8; jpeg-artifacts уже покрывает «испортить». diff --git a/docs/tools-map.md b/docs/tools-map.md index b4830d3..dbe2649 100644 --- a/docs/tools-map.md +++ b/docs/tools-map.md @@ -179,13 +179,7 @@ - sort-pixels — blockSize, ключ (яркость/канал), направление (MEDIUM) - slow-reveal / fade-in / fade-out / disappear — анимационные (→ идеи) -### Сжатие и качество (5, все MEDIUM) - -- compress-png — уровень усилий -- reduce-file-size — целевой размер KB (итеративный поиск) -- optimize-png — пресеты -- change-quality — честная семантика для lossless (см. план-гапы §риск) -- low-quality-png — частично покрыт jpeg-artifacts + quantize +### Сжатие и качество — реализовано (см. Конвертация: compress, reduce-to-size; jpeg-artifacts; W8 quantize) ### Генераторы — остаток diff --git a/web/src/lib/core/compress.test.ts b/web/src/lib/core/compress.test.ts new file mode 100644 index 0000000..fdf4204 --- /dev/null +++ b/web/src/lib/core/compress.test.ts @@ -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) + }); +}); diff --git a/web/src/lib/core/compress.ts b/web/src/lib/core/compress.ts new file mode 100644 index 0000000..d2c954b --- /dev/null +++ b/web/src/lib/core/compress.ts @@ -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 +): Promise { + const hi = Math.max(2, Math.round(maxK)); + let ok = 2; + let fitsAtTwo = false; + const probe = async (k: number): Promise => { + 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; +} diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 0306f37..7241222 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -148,6 +148,11 @@ export const en: Dict = { sizePositive: 'Dimensions must be positive and finite' }, tools: { + 'png-file-size': { + results: { + line: 'PNG size: {kb} KB' + } + }, 'verify-is-png': { results: { verifyYes: 'Yes — this is a valid PNG signature.', diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index 91a8a0b..006d547 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -882,6 +882,33 @@ export const ru: Dict = { 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': { title: 'Показать прозрачные области PNG', description: diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index 6aadf9e..dac607b 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -71,6 +71,9 @@ import { stripDataUri } from './core/textio'; 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 type { Position9 } from './core/textdraw'; import { @@ -2370,6 +2373,69 @@ export const TOOLS: ToolEntry[] = [ 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 => { + 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', title: 'SVG to PNG', diff --git a/web/src/lib/tools/tool-icons.ts b/web/src/lib/tools/tool-icons.ts index 937121b..b8a3334 100644 --- a/web/src/lib/tools/tool-icons.ts +++ b/web/src/lib/tools/tool-icons.ts @@ -27,6 +27,7 @@ Layers, Link2, Maximize2, + Minimize2, Moon, PaintBucket, Palette, @@ -132,6 +133,9 @@ export const TOOL_ICONS: Record = { 'change-aspect-ratio-png': Scaling, 'swap-orientation-png': RotateCw, 'symmetric-copy-png': FlipHorizontal2, + 'compress-png': Minimize2, + 'reduce-to-size-png': Scaling, + 'png-file-size': Info, 'png-to-bytes': Binary, 'bytes-to-png': Binary, 'png-to-rgb-values': Hash,