mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: add limit size to download flow
This commit is contained in:
+36
-9
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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 / байты…",
|
||||||
|
|||||||
Reference in New Issue
Block a user