diff --git a/docs/backlog.md b/docs/backlog.md index ad6ea3e..cab4d08 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -86,15 +86,42 @@ - [ ] **Идея 6. Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download** — оценка M/L. Решение и продуктовое обоснование: `docs/plan-seo.md` §0 и задача **S1f**; почему селектор не заменяет - существующие convert-страницы — `docs/plan-seo.md` §5. Механическая часть - уже есть: `Tool.output` (`OutputFormat`: mime/ext/qualityParamId) и - кодирование по нему в `SchemaToolView.download()` — - `docs/architecture.md`, раздел 3. Что осталось сверх S1f: - экспорт с - лимитом размера → «сжатие до целевого КБ»: для lossy это quality в - селекторе формата, для PNG — бинарный поиск по k из квантования. Нужна ли - отдельная страница под этот интент — открытый вопрос с вордстатом, имя не - выбрано (`docs/plan-seo.md` §5). Проработать вместе с достройкой - download-флоу. + существующие convert-страницы — `docs/plan-seo.md` §5. Селектор формата и + качества в Download уже есть; «сжатие до целевого КБ» реализовано + (`fitWithinBytes` в `web/src/lib/core/io.ts`: бинарный поиск по quality + для lossy, по k квантования для PNG; для BMP — предупреждение, что лимит + не применим). Что осталось: нужна ли отдельная страница под интент «сжать + до N КБ» — открытый вопрос с вордстатом, имя не выбрано + (`docs/plan-seo.md` §5). Проработать вместе с достройкой download-флоу. +- [ ] **S1g под вопросом** — исходная формулировка (`docs/plan-seo.md` §4): на + convert-страницах блокировать в меню Download формат исходника, чтобы + `convert-png-to-jpg` не сохранял в png. Против: пользователь может + захотеть сохранить обработанный PNG или JPEG с другим качеством — блок + мешает легальному сценарию. Решение не принято; перед реализацией + переобдумать. +- [ ] **Лимит КБ: сигнал о недостижимости** — если даже минимальное качество / + k=2 не влезает в лимит, файл скачивается молча с превышением. Нужно: + `fitWithinBytes` (`web/src/lib/core/io.ts`) возвращает также достигнутый + размер, UI показывает warning «не удалось ужаться до N КБ, получилось M». +- [ ] **Лимит КБ: индикация активного сжатия** — сейчас заданный лимит никак не + виден, пока меню Download закрыто: пользователь может забыть про него и + случайно сжать следующие файлы. Нужен постоянный индикатор (значок/ + подпись на кнопке или рядом). Связанный вопрос персистентности: лимит — + `$state` в `SchemaToolView.svelte`, при переходе на другой инструмент + сбрасывается в `init()` и в localStorage не пишется (в отличие от шагов + цепочки). Это безопасное поведение по умолчанию, но стоит осознанно + решить: оставить сброс при смене страницы или сохранять лимит (per-tool? + глобально?) вместе с цепочкой. +- [ ] **Лимит КБ/качество: превью итогового результата** — обдумать UI/UX: при + сжатии по размеру или низкому качеству дать возможность посмотреть + итоговый файл до скачивания — при сильном сжатии (низкий quality, грубое + квантование в PNG) искажения могут быть настолько заметны, что + пользователь откажется от варианта и выберет другой формат/лимит. Сейчас + превью показывает результат инструмента до re-encode, а сжатая версия + существует только внутри `fitWithinBytes`/`encode` в момент скачивания. + Открытые вопросы: превью на лету (дорого — encode на каждый ввод) или по + кнопке «предпросмотр»; где показывать (оверлей/замена тайла результата); + связь с идеей 14 (отображение маски) и пунктом про зум превью выше. - [ ] **Идея 7. Страница `watermark-image-png` (водяной знак картинкой)** — оценка M. Нужна overlay-механика: второй источник изображения (getOverlay/store), параметры scale/opacity/position/margin/aspect. diff --git a/docs/plan-seo.md b/docs/plan-seo.md index 04565a1..c32e9a5 100644 --- a/docs/plan-seo.md +++ b/docs/plan-seo.md @@ -186,7 +186,7 @@ `+layout.svelte`), а не литералом `/og/…`: под `BASE_PATH` корневой путь ломается. Домен для этого не нужен; при деплое на собственном домене OG-картинки будут абсолютными и корректными. -- [ ] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality +- [x] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality для lossy) для всех инструментов — механика `Tool.output` уже есть (`docs/backlog.md`, «Идеи» №6, UX-райз «Download»). Продуктовая основа «image tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести @@ -197,7 +197,9 @@ что селектор формата его не покрывает. - [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов формат исходника (disable) — чтобы `convert-png-to-jpg` не мог сохранить в - png. Существующие страницы не трогать: `convert-png-to-jpg`, + png. **Под вопросом:** блок мешает легальному сценарию «сохранить + обработанный PNG / JPEG с другим качеством»; решение не принято, см. + `docs/backlog.md`. Существующие страницы не трогать: `convert-png-to-jpg`, `convert-png-to-webp`, `png-to-bmp` остаются самостоятельными лендингами со своим контентом (§5). @@ -313,6 +315,13 @@ > смыслу: `round-corners-png` лежит в `alpha`, `add-text-png` и > `watermark-tile-png` — в `text`. +> **Открытый вопрос по интенту «сжать до N КБ».** Лимит размера теперь живёт в +> кнопке Download (`fitWithinBytes` в `web/src/lib/core/io.ts`); интенты вида +> «compress png to 500kb / 1mb» — high-volume и лимит-ориентированные, селектор +> в Download их не закрывает как посадочная. Решение (отдельная страница / приём +> в контенте существующих / ничего) требует вордстата; имя страницы не выбрано. +> Прорабатывать вместе с вопросом «из формата в PNG» выше. + > **Открытый вопрос по интентам «из формата в PNG».** У конкурента есть > `jpg-to-png`, `webp-to-png`, `gif-to-png` как отдельные страницы; у нас таких > инструментов нет и создавать их не хочется — загрузка файла уже принимает diff --git a/web/src/lib/components/schema/SchemaActions.svelte b/web/src/lib/components/schema/SchemaActions.svelte index e071803..00a0286 100644 --- a/web/src/lib/components/schema/SchemaActions.svelte +++ b/web/src/lib/components/schema/SchemaActions.svelte @@ -14,11 +14,13 @@ running: boolean; format?: OutputMime; quality?: number; + limitKb?: number; alphaLoss?: boolean; onupload: (file: File) => void; ondownload: () => void; onformat?: (mime: OutputMime) => void; onquality?: (value: number) => void; + onlimit?: (kb: number | undefined) => void; } let { inputMode, @@ -27,11 +29,13 @@ running, format = "image/png", quality = undefined, + limitKb = undefined, alphaLoss = false, onupload, ondownload, onformat, onquality, + onlimit, }: Props = $props(); @@ -43,10 +47,12 @@ onformat?.(v)} onquality={(v) => onquality?.(v)} + onlimit={(v) => onlimit?.(v)} /> {:else if canDownload && resultKind === "files"} void; onformat: (mime: OutputMime) => void; onquality: (value: number) => void; + onlimit: (kb: number | undefined) => void; } - let { format, quality, running, ondownload, onformat, onquality }: Props = - $props(); + let { + format, + quality, + limitKb, + running, + ondownload, + onformat, + onquality, + onlimit, + }: Props = $props(); const current = $derived(outputFormatByMime(format)); const qualitySetting = $derived(current.settings?.quality); @@ -44,6 +54,11 @@ onformat(mime); } + function handleLimit(e: Event) { + const raw = (e.target as HTMLInputElement).valueAsNumber; + onlimit(Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : undefined); + } + $effect(() => { if (!open) return; function onPointerDown(e: PointerEvent) { @@ -110,6 +125,24 @@ /> {/if} +
+ + {#if limitKb !== undefined && format === "image/bmp"} +

+ {t("actions.sizeLimitBmp")} +

+ {/if} +
{/if} @@ -183,4 +216,37 @@ padding-top: var(--space-m); border-top: var(--size-border) solid var(--color-border); } + .limit { + padding-top: var(--space-m); + border-top: var(--size-border) solid var(--color-border); + } + .limit-field { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-m); + color: var(--color-text-muted); + font: var(--font-size-s) var(--font-mono); + } + .limit-field input { + width: calc(var(--space-xxxl) * 3); + padding: var(--space-m) var(--space-l); + background: var(--color-background); + border: var(--size-border) solid var(--color-border); + border-radius: var(--radius-s); + color: var(--color-text); + font: var(--font-size-m) var(--font-mono); + text-align: center; + appearance: textfield; + } + .limit-field input::-webkit-outer-spin-button, + .limit-field input::-webkit-inner-spin-button { + appearance: none; + margin: 0; + } + .limit-warn { + margin: var(--space-m) 0 0; + color: var(--color-warning); + font: var(--font-size-s) var(--font-mono); + } diff --git a/web/src/lib/components/schema/SchemaPreview.svelte b/web/src/lib/components/schema/SchemaPreview.svelte index bbf4962..fd65bc0 100644 --- a/web/src/lib/components/schema/SchemaPreview.svelte +++ b/web/src/lib/components/schema/SchemaPreview.svelte @@ -34,6 +34,7 @@ ontogglealign?: () => void; format?: OutputMime; quality?: number; + limitKb?: number; alphaLoss?: boolean; onupload: (file: File) => void; ontextsource?: (text: string) => void; @@ -43,6 +44,7 @@ ondownload: () => void; onformat?: (mime: OutputMime) => void; onquality?: (value: number) => void; + onlimit?: (kb: number | undefined) => void; } let { inputMode, @@ -62,6 +64,7 @@ ontogglealign, format = "image/png", quality = undefined, + limitKb = undefined, alphaLoss = false, onupload, ontextsource, @@ -71,6 +74,7 @@ ondownload, onformat, onquality, + onlimit, }: Props = $props(); const hasResult = $derived( @@ -97,11 +101,13 @@ {running} {format} {quality} + {limitKb} {alphaLoss} {onupload} {ondownload} {onformat} {onquality} + {onlimit} /> diff --git a/web/src/lib/components/schema/SchemaToolView.svelte b/web/src/lib/components/schema/SchemaToolView.svelte index 2262bd5..88eddfc 100644 --- a/web/src/lib/components/schema/SchemaToolView.svelte +++ b/web/src/lib/components/schema/SchemaToolView.svelte @@ -10,6 +10,7 @@ decodeFile, downloadBlob, encode, + fitWithinBytes, outputFormatByMime, toDataUrl, type OutputMime, @@ -98,6 +99,8 @@ let format = $state("image/png"); // Lossy-format quality for tools without a quality param in the schema. let formatQuality = $state>({}); + // Optional download size limit (KB); undefined = no limit. + let limitKb = $state(undefined); let dragFrom = $state(null); const lastTool = $derived( @@ -174,6 +177,7 @@ initedFor = page.slug; steps = loadChain(page.slug) ?? createChain(page); format = lastTool.output?.mime ?? "image/png"; + limitKb = undefined; source = null; result = null; fileResult = null; @@ -397,7 +401,9 @@ const out = outputFormatByMime(format); const quality = currentQuality !== undefined ? currentQuality / 100 : undefined; - const blob = await encode(result, out.mime, quality); + const blob = limitKb + ? await fitWithinBytes(result, out.mime, limitKb * 1024, quality) + : await encode(result, out.mime, quality); downloadBlob(blob, `${page.slug}.${out.ext}`); } catch (e) { displayError = toDisplayError(e); @@ -569,12 +575,14 @@ error={errorText} {format} quality={currentQuality} + {limitKb} {alphaLoss} {stepResults} {aligned} ontogglealign={() => (aligned = !aligned)} onformat={(v) => (format = v)} onquality={setFormatQuality} + onlimit={(v) => (limitKb = v)} onupload={handleFile} ontextsource={(textValue) => { textSource = textValue; @@ -603,11 +611,13 @@ {running} {format} quality={currentQuality} + {limitKb} {alphaLoss} onupload={handleFile} ondownload={download} onformat={(v) => (format = v)} onquality={setFormatQuality} + onlimit={(v) => (limitKb = v)} /> diff --git a/web/src/lib/core/compress.test.ts b/web/src/lib/core/compress.test.ts index 33249a4..1f3b79c 100644 --- a/web/src/lib/core/compress.test.ts +++ b/web/src/lib/core/compress.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { COMPRESSION_LEVELS, findMaxColorsWithin } from "./compress"; +import { + COMPRESSION_LEVELS, + findMaxColorsWithin, + findQualityWithin, +} from "./compress"; describe("COMPRESSION_LEVELS", () => { it("presets ordered by descending colors", () => { @@ -38,3 +42,38 @@ describe("findMaxColorsWithin", () => { expect(calls).toBeLessThanOrEqual(12); // log2(254)+probe(2) }); }); + +describe("findQualityWithin", () => { + const sizeOf = (q: number) => 500 + q * 50; // size grows with quality + + it("picks max quality fitting the target", async () => { + // target 5000 -> q<=90 fits + const q = await findQualityWithin(5000, async (qq) => sizeOf(qq)); + expect(q).toBe(90); + expect(sizeOf(q)).toBeLessThanOrEqual(5000); + }); + + it("everything fits - returns max quality", async () => { + const q = await findQualityWithin(999999, async (qq) => sizeOf(qq)); + expect(q).toBe(100); + }); + + it("target unreachable even at minimum - returns min", async () => { + const q = await findQualityWithin(10, async (qq) => sizeOf(qq)); + expect(q).toBe(1); + }); + + it("encodeSize null treated as failure", async () => { + const q = await findQualityWithin(999999, async () => null); + expect(q).toBe(1); + }); + + it("binary search converges faster than full scan", async () => { + let calls = 0; + await findQualityWithin(3000, async (qq) => { + calls++; + return sizeOf(qq); + }); + expect(calls).toBeLessThanOrEqual(10); // log2(99)+2 probes + }); +}); diff --git a/web/src/lib/core/compress.ts b/web/src/lib/core/compress.ts index c5d0513..fc5b8c0 100644 --- a/web/src/lib/core/compress.ts +++ b/web/src/lib/core/compress.ts @@ -44,3 +44,39 @@ export async function findMaxColorsWithin( void fitsAtTwo; return ok; } + +/** + * Binary search for the highest quality percent in [min..max] whose encoded + * size fits targetBytes. If even min does not fit, returns min (best effort). + * encodeSize may return null (encode error), treated as "does not fit". + */ +export async function findQualityWithin( + targetBytes: number, + encodeSize: (quality: number) => Promise, + min = 1, + max = 100, +): Promise { + const lo = Math.max(1, Math.round(min)); + const hi = Math.max(lo, Math.round(max)); + let ok = lo; + const probe = async (q: number): Promise => { + const size = await encodeSize(Math.max(lo, Math.min(hi, q))); + if (size === null) return false; + if (size <= targetBytes) { + ok = q; + return true; + } + return false; + }; + + if (!(await probe(lo))) return lo; + if (await probe(hi)) return hi; + let a = lo; + let b = hi; + while (b - a > 1) { + const mid = Math.floor((a + b) / 2); + if (await probe(mid)) a = mid; + else b = mid; + } + return ok; +} diff --git a/web/src/lib/core/io.ts b/web/src/lib/core/io.ts index a69a3fd..45dae16 100644 --- a/web/src/lib/core/io.ts +++ b/web/src/lib/core/io.ts @@ -1,5 +1,7 @@ import { encodeBmpBytes } from "./bmp"; +import { findMaxColorsWithin, findQualityWithin } from "./compress"; import { ToolError } from "./errors"; +import { quantizeImage } from "./quantize"; import { type PixelImage } from "./types"; export type OutputMime = @@ -181,6 +183,32 @@ function canvasToBlob( }); } +/** + * Encodes img to fit targetBytes: binary search over quality for lossy + * formats, over quantization colors for PNG. BMP has no compression knobs -- + * returned as-is (the caller warns the user the limit does not apply). + */ +export async function fitWithinBytes( + img: PixelImage, + mime: OutputMime, + targetBytes: number, + quality?: number, +): Promise { + if (mime === "image/bmp") return encode(img, mime); + const initial = await encode(img, mime, quality); + if (initial.size <= targetBytes) return initial; + if (mime === "image/png") { + const encodeSize = async (k: number) => + (await encode(quantizeImage(img, k).image, mime)).size; + const k = await findMaxColorsWithin(targetBytes, 256, encodeSize); + return encode(quantizeImage(img, k).image, mime); + } + const encodeSize = async (q: number) => + (await encode(img, mime, q / 100)).size; + const q = await findQualityWithin(targetBytes, encodeSize); + return encode(img, mime, q / 100); +} + export function downloadBlob(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); const a = document.createElement("a"); diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 60f9358..612e866 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -111,6 +111,9 @@ export const en: Dict = { downloadFormat: "Download {format}", formatAria: "Choose download format", alphaLoss: "Result has transparency — {format} will flatten it", + sizeLimitKb: "Max size, KB", + sizeLimitBmp: + "Size limit does not apply to BMP — it is always uncompressed", }, textSource: { placeholder: "Paste base64 / hex / bytes…", diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index d574f26..ec2f838 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -112,6 +112,8 @@ export const ru: Dict = { downloadFormat: "Скачать {format}", formatAria: "Выбор формата скачивания", alphaLoss: "В результате есть прозрачность — {format} её уберёт", + sizeLimitKb: "Лимит, КБ", + sizeLimitBmp: "Лимит размера не действует на BMP — он всегда несжатый", }, textSource: { placeholder: "Вставьте base64 / hex / байты…",