feat: add limit size to download flow

This commit is contained in:
2026-10-08 17:42:21 +05:00
parent 7361a82c18
commit 8dcc263461
11 changed files with 247 additions and 15 deletions
+36 -9
View File
@@ -86,15 +86,42 @@
- [ ] **Идея 6. Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке - [ ] **Идея 6. Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке
Download** — оценка M/L. Решение и продуктовое обоснование: Download** — оценка M/L. Решение и продуктовое обоснование:
`docs/plan-seo.md` §0 и задача **S1f**; почему селектор не заменяет `docs/plan-seo.md` §0 и задача **S1f**; почему селектор не заменяет
существующие convert-страницы — `docs/plan-seo.md` §5. Механическая часть существующие convert-страницы — `docs/plan-seo.md` §5. Селектор формата и
уже есть: `Tool.output` (`OutputFormat`: mime/ext/qualityParamId) и качества в Download уже есть; «сжатие до целевого КБ» реализовано
кодирование по нему в `SchemaToolView.download()` — (`fitWithinBytes` в `web/src/lib/core/io.ts`: бинарный поиск по quality
`docs/architecture.md`, раздел 3. Что осталось сверх S1f: - экспорт с для lossy, по k квантования для PNG; для BMP — предупреждение, что лимит
лимитом размера → «сжатие до целевого КБ»: для lossy это quality в не применим). Что осталось: нужна ли отдельная страница под интент «сжать
селекторе формата, для PNG — бинарный поиск по k из квантования. Нужна ли до N КБ» — открытый вопрос с вордстатом, имя не выбрано
отдельная страница под этот интент — открытый вопрос с вордстатом, имя не (`docs/plan-seo.md` §5). Проработать вместе с достройкой download-флоу.
выбрано (`docs/plan-seo.md` §5). Проработать вместе с достройкой - [ ] **S1g под вопросом** — исходная формулировка (`docs/plan-seo.md` §4): на
download-флоу. 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` (водяной знак картинкой)** — - [ ] **Идея 7. Страница `watermark-image-png` (водяной знак картинкой)** —
оценка M. Нужна overlay-механика: второй источник изображения оценка M. Нужна overlay-механика: второй источник изображения
(getOverlay/store), параметры scale/opacity/position/margin/aspect. (getOverlay/store), параметры scale/opacity/position/margin/aspect.
+11 -2
View File
@@ -186,7 +186,7 @@
`+layout.svelte`), а не литералом `/og/…`: под `BASE_PATH` корневой путь `+layout.svelte`), а не литералом `/og/…`: под `BASE_PATH` корневой путь
ломается. Домен для этого не нужен; при деплое на собственном домене ломается. Домен для этого не нужен; при деплое на собственном домене
OG-картинки будут абсолютными и корректными. OG-картинки будут абсолютными и корректными.
- [ ] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality - [x] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality
для lossy) для всех инструментов — механика `Tool.output` уже есть для lossy) для всех инструментов — механика `Tool.output` уже есть
(`docs/backlog.md`, «Идеи» №6, UX-райз «Download»). Продуктовая основа (`docs/backlog.md`, «Идеи» №6, UX-райз «Download»). Продуктовая основа
«image tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести «image tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
@@ -197,7 +197,9 @@
что селектор формата его не покрывает. что селектор формата его не покрывает.
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов - [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
формат исходника (disable) — чтобы `convert-png-to-jpg` не мог сохранить в формат исходника (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` остаются самостоятельными лендингами `convert-png-to-webp`, `png-to-bmp` остаются самостоятельными лендингами
со своим контентом (§5). со своим контентом (§5).
@@ -313,6 +315,13 @@
> смыслу: `round-corners-png` лежит в `alpha`, `add-text-png` и > смыслу: `round-corners-png` лежит в `alpha`, `add-text-png` и
> `watermark-tile-png` — в `text`. > `watermark-tile-png` — в `text`.
> **Открытый вопрос по интенту «сжать до N КБ».** Лимит размера теперь живёт в
> кнопке Download (`fitWithinBytes` в `web/src/lib/core/io.ts`); интенты вида
> «compress png to 500kb / 1mb» — high-volume и лимит-ориентированные, селектор
> в Download их не закрывает как посадочная. Решение (отдельная страница / приём
> в контенте существующих / ничего) требует вордстата; имя страницы не выбрано.
> Прорабатывать вместе с вопросом «из формата в PNG» выше.
> **Открытый вопрос по интентам «из формата в PNG».** У конкурента есть > **Открытый вопрос по интентам «из формата в PNG».** У конкурента есть
> `jpg-to-png`, `webp-to-png`, `gif-to-png` как отдельные страницы; у нас таких > `jpg-to-png`, `webp-to-png`, `gif-to-png` как отдельные страницы; у нас таких
> инструментов нет и создавать их не хочется — загрузка файла уже принимает > инструментов нет и создавать их не хочется — загрузка файла уже принимает
@@ -14,11 +14,13 @@
running: boolean; running: boolean;
format?: OutputMime; format?: OutputMime;
quality?: number; quality?: number;
limitKb?: number;
alphaLoss?: boolean; alphaLoss?: boolean;
onupload: (file: File) => void; onupload: (file: File) => void;
ondownload: () => void; ondownload: () => void;
onformat?: (mime: OutputMime) => void; onformat?: (mime: OutputMime) => void;
onquality?: (value: number) => void; onquality?: (value: number) => void;
onlimit?: (kb: number | undefined) => void;
} }
let { let {
inputMode, inputMode,
@@ -27,11 +29,13 @@
running, running,
format = "image/png", format = "image/png",
quality = undefined, quality = undefined,
limitKb = undefined,
alphaLoss = false, alphaLoss = false,
onupload, onupload,
ondownload, ondownload,
onformat, onformat,
onquality, onquality,
onlimit,
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -43,10 +47,12 @@
<SchemaDownload <SchemaDownload
{format} {format}
{quality} {quality}
{limitKb}
{running} {running}
{ondownload} {ondownload}
onformat={(v) => onformat?.(v)} onformat={(v) => onformat?.(v)}
onquality={(v) => onquality?.(v)} onquality={(v) => onquality?.(v)}
onlimit={(v) => onlimit?.(v)}
/> />
{:else if canDownload && resultKind === "files"} {:else if canDownload && resultKind === "files"}
<DownloadButton <DownloadButton
@@ -13,13 +13,23 @@
interface Props { interface Props {
format: OutputMime; format: OutputMime;
quality?: number; quality?: number;
limitKb?: number;
running: boolean; running: boolean;
ondownload: () => void; ondownload: () => void;
onformat: (mime: OutputMime) => void; onformat: (mime: OutputMime) => void;
onquality: (value: number) => void; onquality: (value: number) => void;
onlimit: (kb: number | undefined) => void;
} }
let { format, quality, running, ondownload, onformat, onquality }: Props = let {
$props(); format,
quality,
limitKb,
running,
ondownload,
onformat,
onquality,
onlimit,
}: Props = $props();
const current = $derived(outputFormatByMime(format)); const current = $derived(outputFormatByMime(format));
const qualitySetting = $derived(current.settings?.quality); const qualitySetting = $derived(current.settings?.quality);
@@ -44,6 +54,11 @@
onformat(mime); onformat(mime);
} }
function handleLimit(e: Event) {
const raw = (e.target as HTMLInputElement).valueAsNumber;
onlimit(Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : undefined);
}
$effect(() => { $effect(() => {
if (!open) return; if (!open) return;
function onPointerDown(e: PointerEvent) { function onPointerDown(e: PointerEvent) {
@@ -110,6 +125,24 @@
/> />
</div> </div>
{/if} {/if}
<div class="limit">
<label class="limit-field">
<span>{t("actions.sizeLimitKb")}</span>
<input
type="number"
min="1"
step="1"
placeholder="∞"
value={limitKb ?? ""}
oninput={handleLimit}
/>
</label>
{#if limitKb !== undefined && format === "image/bmp"}
<p class="limit-warn" role="status">
{t("actions.sizeLimitBmp")}
</p>
{/if}
</div>
</div> </div>
{/if} {/if}
</div> </div>
@@ -183,4 +216,37 @@
padding-top: var(--space-m); padding-top: var(--space-m);
border-top: var(--size-border) solid var(--color-border); 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);
}
</style> </style>
@@ -34,6 +34,7 @@
ontogglealign?: () => void; ontogglealign?: () => void;
format?: OutputMime; format?: OutputMime;
quality?: number; quality?: number;
limitKb?: number;
alphaLoss?: boolean; alphaLoss?: boolean;
onupload: (file: File) => void; onupload: (file: File) => void;
ontextsource?: (text: string) => void; ontextsource?: (text: string) => void;
@@ -43,6 +44,7 @@
ondownload: () => void; ondownload: () => void;
onformat?: (mime: OutputMime) => void; onformat?: (mime: OutputMime) => void;
onquality?: (value: number) => void; onquality?: (value: number) => void;
onlimit?: (kb: number | undefined) => void;
} }
let { let {
inputMode, inputMode,
@@ -62,6 +64,7 @@
ontogglealign, ontogglealign,
format = "image/png", format = "image/png",
quality = undefined, quality = undefined,
limitKb = undefined,
alphaLoss = false, alphaLoss = false,
onupload, onupload,
ontextsource, ontextsource,
@@ -71,6 +74,7 @@
ondownload, ondownload,
onformat, onformat,
onquality, onquality,
onlimit,
}: Props = $props(); }: Props = $props();
const hasResult = $derived( const hasResult = $derived(
@@ -97,11 +101,13 @@
{running} {running}
{format} {format}
{quality} {quality}
{limitKb}
{alphaLoss} {alphaLoss}
{onupload} {onupload}
{ondownload} {ondownload}
{onformat} {onformat}
{onquality} {onquality}
{onlimit}
/> />
</div> </div>
@@ -10,6 +10,7 @@
decodeFile, decodeFile,
downloadBlob, downloadBlob,
encode, encode,
fitWithinBytes,
outputFormatByMime, outputFormatByMime,
toDataUrl, toDataUrl,
type OutputMime, type OutputMime,
@@ -98,6 +99,8 @@
let format = $state<OutputMime>("image/png"); let format = $state<OutputMime>("image/png");
// Lossy-format quality for tools without a quality param in the schema. // Lossy-format quality for tools without a quality param in the schema.
let formatQuality = $state<Record<string, number>>({}); let formatQuality = $state<Record<string, number>>({});
// Optional download size limit (KB); undefined = no limit.
let limitKb = $state<number | undefined>(undefined);
let dragFrom = $state<number | null>(null); let dragFrom = $state<number | null>(null);
const lastTool = $derived( const lastTool = $derived(
@@ -174,6 +177,7 @@
initedFor = page.slug; initedFor = page.slug;
steps = loadChain(page.slug) ?? createChain(page); steps = loadChain(page.slug) ?? createChain(page);
format = lastTool.output?.mime ?? "image/png"; format = lastTool.output?.mime ?? "image/png";
limitKb = undefined;
source = null; source = null;
result = null; result = null;
fileResult = null; fileResult = null;
@@ -397,7 +401,9 @@
const out = outputFormatByMime(format); const out = outputFormatByMime(format);
const quality = const quality =
currentQuality !== undefined ? currentQuality / 100 : undefined; 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}`); downloadBlob(blob, `${page.slug}.${out.ext}`);
} catch (e) { } catch (e) {
displayError = toDisplayError(e); displayError = toDisplayError(e);
@@ -569,12 +575,14 @@
error={errorText} error={errorText}
{format} {format}
quality={currentQuality} quality={currentQuality}
{limitKb}
{alphaLoss} {alphaLoss}
{stepResults} {stepResults}
{aligned} {aligned}
ontogglealign={() => (aligned = !aligned)} ontogglealign={() => (aligned = !aligned)}
onformat={(v) => (format = v)} onformat={(v) => (format = v)}
onquality={setFormatQuality} onquality={setFormatQuality}
onlimit={(v) => (limitKb = v)}
onupload={handleFile} onupload={handleFile}
ontextsource={(textValue) => { ontextsource={(textValue) => {
textSource = textValue; textSource = textValue;
@@ -603,11 +611,13 @@
{running} {running}
{format} {format}
quality={currentQuality} quality={currentQuality}
{limitKb}
{alphaLoss} {alphaLoss}
onupload={handleFile} onupload={handleFile}
ondownload={download} ondownload={download}
onformat={(v) => (format = v)} onformat={(v) => (format = v)}
onquality={setFormatQuality} onquality={setFormatQuality}
onlimit={(v) => (limitKb = v)}
/> />
</div> </div>
</div> </div>
+40 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { COMPRESSION_LEVELS, findMaxColorsWithin } from "./compress"; import {
COMPRESSION_LEVELS,
findMaxColorsWithin,
findQualityWithin,
} from "./compress";
describe("COMPRESSION_LEVELS", () => { describe("COMPRESSION_LEVELS", () => {
it("presets ordered by descending colors", () => { it("presets ordered by descending colors", () => {
@@ -38,3 +42,38 @@ describe("findMaxColorsWithin", () => {
expect(calls).toBeLessThanOrEqual(12); // log2(254)+probe(2) 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
});
});
+36
View File
@@ -44,3 +44,39 @@ export async function findMaxColorsWithin(
void fitsAtTwo; void fitsAtTwo;
return ok; 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<number | null>,
min = 1,
max = 100,
): Promise<number> {
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<boolean> => {
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;
}
+28
View File
@@ -1,5 +1,7 @@
import { encodeBmpBytes } from "./bmp"; import { encodeBmpBytes } from "./bmp";
import { findMaxColorsWithin, findQualityWithin } from "./compress";
import { ToolError } from "./errors"; import { ToolError } from "./errors";
import { quantizeImage } from "./quantize";
import { type PixelImage } from "./types"; import { type PixelImage } from "./types";
export type OutputMime = 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<Blob> {
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 { export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
+3
View File
@@ -111,6 +111,9 @@ export const en: Dict = {
downloadFormat: "Download {format}", downloadFormat: "Download {format}",
formatAria: "Choose download format", formatAria: "Choose download format",
alphaLoss: "Result has transparency — {format} will flatten it", 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: { textSource: {
placeholder: "Paste base64 / hex / bytes…", placeholder: "Paste base64 / hex / bytes…",
+2
View File
@@ -112,6 +112,8 @@ export const ru: Dict = {
downloadFormat: "Скачать {format}", downloadFormat: "Скачать {format}",
formatAria: "Выбор формата скачивания", formatAria: "Выбор формата скачивания",
alphaLoss: "В результате есть прозрачность — {format} её уберёт", alphaLoss: "В результате есть прозрачность — {format} её уберёт",
sizeLimitKb: "Лимит, КБ",
sizeLimitBmp: "Лимит размера не действует на BMP — он всегда несжатый",
}, },
textSource: { textSource: {
placeholder: "Вставьте base64 / hex / байты…", placeholder: "Вставьте base64 / hex / байты…",