From f67ddb3b0a7c69316caa3bed30d976f41d7a1219 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sun, 11 Oct 2026 10:31:50 +0500 Subject: [PATCH] feat: add download size limit warning --- docs/backlog.md | 10 ----- .../lib/components/schema/PreviewTile.svelte | 7 ++++ .../components/schema/SchemaResultTile.svelte | 3 ++ .../components/schema/SchemaToolView.svelte | 15 +++----- .../schema/schema-tool-runner.svelte.ts | 34 +++++++++++++++-- web/src/lib/core/compress.test.ts | 37 +++++++++++++++++++ web/src/lib/core/compress.ts | 22 ++++++++--- web/src/lib/i18n/en.ts | 2 + web/src/lib/i18n/ru.ts | 2 + web/src/lib/i18n/schema-tool-strings.ts | 5 +++ web/src/lib/run-chain.ts | 3 +- 11 files changed, 111 insertions(+), 29 deletions(-) diff --git a/docs/backlog.md b/docs/backlog.md index b5f723b..9040549 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -10,12 +10,6 @@ ## Pipeline -- [ ] **баг: drag-n-drop перестановки шагов не работает** — обработчики на месте - (`StepCard` draggable в сниппете `stepCard` `SchemaToolView.svelte`, - `onStepDrop` → `moveStep` в `schema-tool-state.svelte.ts`), но - перетаскивание не срабатывает. Замечено до рефактора H1 (2026-10), не - регрессия от него. Начать с проверки `draggable`/`dragstart` на `StepCard` - и того, что drop не глушится дочерними элементами карточки - [ ] названия инструментов — в pipeline инструмент называется «обрезать png», но он работает с любыми картинками, наверное надо убрать; в заголовке страницы — возможно, надо указать все форматы (связано с SEO) @@ -74,10 +68,6 @@ захотеть сохранить обработанный PNG или JPEG с другим качеством — блок мешает легальному сценарию. Решение не принято; перед реализацией переобдумать. -- [ ] **Лимит КБ: сигнал о недостижимости** — если даже минимальное качество / - k=2 не влезает в лимит, файл скачивается молча с превышением. Нужно: - `fitWithinBytes` (`web/src/lib/core/io.ts`) возвращает также достигнутый - размер, UI показывает warning «не удалось ужаться до N КБ, получилось M». - [ ] **Лимит КБ: индикация активного сжатия** — сейчас заданный лимит никак не виден, пока меню Download закрыто: пользователь может забыть про него и случайно сжать следующие файлы. Нужен постоянный индикатор (значок/ diff --git a/web/src/lib/components/schema/PreviewTile.svelte b/web/src/lib/components/schema/PreviewTile.svelte index 7fc657e..14bcd35 100644 --- a/web/src/lib/components/schema/PreviewTile.svelte +++ b/web/src/lib/components/schema/PreviewTile.svelte @@ -15,6 +15,9 @@ dims?: string; note?: string; noteTone?: "warning" | "info"; + // Runtime warnings (chain/download events): shown as badges next to the + // derived result note, but from a different source. + warnings?: string[]; actions?: Snippet; } let { @@ -27,6 +30,7 @@ dims = undefined, note = undefined, noteTone = "warning", + warnings = [], actions = undefined, }: Props = $props(); @@ -52,6 +56,9 @@ {#if note} {/if} + {#each warnings as warning (warning)} + + {/each}
diff --git a/web/src/lib/components/schema/SchemaResultTile.svelte b/web/src/lib/components/schema/SchemaResultTile.svelte index 93608be..4bcab9f 100644 --- a/web/src/lib/components/schema/SchemaResultTile.svelte +++ b/web/src/lib/components/schema/SchemaResultTile.svelte @@ -13,6 +13,7 @@ resultKind?: ResultKind; result: PixelImage | null; resultNote?: ResultNote | null; + warnings?: string[]; fileResult?: FileResult | null; textResult?: string | null; textVars?: Record; @@ -28,6 +29,7 @@ resultKind = "image", result, resultNote = null, + warnings = [], fileResult = null, textResult = null, textVars = undefined, @@ -68,6 +70,7 @@ dims={resultDims} note={resultNote ? t(resultNote.key) : undefined} noteTone={resultNote?.tone ?? "warning"} + {warnings} > {#snippet actions()} {#if ontogglemask} diff --git a/web/src/lib/components/schema/SchemaToolView.svelte b/web/src/lib/components/schema/SchemaToolView.svelte index 959c71b..2471cb6 100644 --- a/web/src/lib/components/schema/SchemaToolView.svelte +++ b/web/src/lib/components/schema/SchemaToolView.svelte @@ -123,7 +123,11 @@ const stepFileSets = $derived(runner.stepFileSets); const runWarnings = $derived(runner.runWarnings); const displayError = $derived(runner.displayError); - const allWarnings = $derived([...upload.sourceWarnings, ...runWarnings]); + const warningTexts = $derived( + [...upload.sourceWarnings, ...runWarnings, ...runner.downloadWarnings].map( + chainWarningText, + ), + ); const masks = createStepMasks({ steps: () => steps, @@ -316,6 +320,7 @@ resultKind={displayResultKind} result={out} {resultNote} + warnings={warningTexts} {fileResult} {textResult} textVars={verdictVars} @@ -440,9 +445,6 @@ {#if errorText} {/if} - {#each allWarnings as w (chainWarningText(w))} -

{chainWarningText(w)}

- {/each}
@@ -620,11 +622,6 @@ .no-schema { font: var(--font-size-s) var(--font-mono); } - .warn { - margin: var(--space-s) 0 0; - color: var(--color-warning); - font: var(--font-size-s) var(--font-mono); - } @media (--bp-tablet) { .workspace, .workspace.aligned { diff --git a/web/src/lib/components/schema/schema-tool-runner.svelte.ts b/web/src/lib/components/schema/schema-tool-runner.svelte.ts index febcf2e..a49c1f0 100644 --- a/web/src/lib/components/schema/schema-tool-runner.svelte.ts +++ b/web/src/lib/components/schema/schema-tool-runner.svelte.ts @@ -56,6 +56,8 @@ interface RunnerState { // Full named set per step (batch/fan-out); intermediate tiles show a grid. stepFileSets: ToolImageFile[][]; runWarnings: ChainWarning[]; + // Size-limit feedback from the last download (limit unreachable etc.). + downloadWarnings: ChainWarning[]; displayError: DisplayError | null; } @@ -153,10 +155,29 @@ async function download(s: RunnerState, ctx: SchemaToolRunnerCtx) { const q = ctx.quality(); const limit = ctx.limitKb(); const quality = q !== undefined ? q / 100 : undefined; - const blob = limit - ? await fitWithinBytes(s.result, out.mime, limit * 1024, quality, encode) - : await encode(s.result, out.mime, quality); - downloadBlob(blob, `${ctx.pageSlug()}.${out.ext}`); + s.downloadWarnings = []; + if (limit) { + const fitted = await fitWithinBytes( + s.result, + out.mime, + limit * 1024, + quality, + encode, + ); + if (fitted.achievedBytes > limit * 1024) { + s.downloadWarnings = [ + { + kind: "sizeLimitMiss", + limitKb: limit, + actualKb: Math.ceil(fitted.achievedBytes / 1024), + }, + ]; + } + downloadBlob(fitted.blob, `${ctx.pageSlug()}.${out.ext}`); + } else { + const blob = await encode(s.result, out.mime, quality); + downloadBlob(blob, `${ctx.pageSlug()}.${out.ext}`); + } } catch (e) { s.displayError = toDisplayError(e); } finally { @@ -193,6 +214,7 @@ function clearResults(s: RunnerState) { s.fileResult = null; s.textResult = null; s.verdictVars = undefined; + s.downloadWarnings = []; s.displayError = null; } @@ -211,6 +233,7 @@ export function createSchemaToolRunner(ctx: SchemaToolRunnerCtx) { stepResults: [], stepFileSets: [], runWarnings: [], + downloadWarnings: [], displayError: null, }); return { @@ -244,6 +267,9 @@ export function createSchemaToolRunner(ctx: SchemaToolRunnerCtx) { get runWarnings() { return s.runWarnings; }, + get downloadWarnings() { + return s.downloadWarnings; + }, get displayError() { return s.displayError; }, diff --git a/web/src/lib/core/compress.test.ts b/web/src/lib/core/compress.test.ts index 1f3b79c..d865d6b 100644 --- a/web/src/lib/core/compress.test.ts +++ b/web/src/lib/core/compress.test.ts @@ -3,7 +3,16 @@ import { COMPRESSION_LEVELS, findMaxColorsWithin, findQualityWithin, + fitWithinBytes, } from "./compress"; +import { createPixelImage, type PixelImage } from "./types"; + +const img: PixelImage = createPixelImage(4, 4); +const blobOf = (size: number) => new Blob([new Uint8Array(size)]); +const encodeByQuality = + (sizeOf: (q: number) => number) => + async (_img: PixelImage, _mime: string, quality?: number) => + blobOf(sizeOf(Math.round((quality ?? 1) * 100))); describe("COMPRESSION_LEVELS", () => { it("presets ordered by descending colors", () => { @@ -77,3 +86,31 @@ describe("findQualityWithin", () => { expect(calls).toBeLessThanOrEqual(10); // log2(99)+2 probes }); }); + +describe("fitWithinBytes", () => { + it("bmp ignores the limit and reports the raw size", async () => { + const r = await fitWithinBytes(img, "image/bmp", 100, undefined, async () => + blobOf(5000), + ); + expect(r.achievedBytes).toBe(5000); + expect(r.achievedBytes).toBeGreaterThan(100); + }); + + it("returns the initial encode when it already fits", async () => { + const encode = encodeByQuality(() => 500); + const r = await fitWithinBytes(img, "image/jpeg", 1000, 0.8, encode); + expect(r.achievedBytes).toBe(500); + }); + + it("lossy: searches quality until the target fits", async () => { + const encode = encodeByQuality((q) => 500 + q * 50); + const r = await fitWithinBytes(img, "image/jpeg", 5000, 1, encode); + expect(r.achievedBytes).toBeLessThanOrEqual(5000); + }); + + it("lossy: unreachable target reports achievedBytes above the limit", async () => { + const encode = encodeByQuality((q) => 500 + q * 50); + const r = await fitWithinBytes(img, "image/webp", 100, 1, encode); + expect(r.achievedBytes).toBeGreaterThan(100); + }); +}); diff --git a/web/src/lib/core/compress.ts b/web/src/lib/core/compress.ts index e7665e9..375fe72 100644 --- a/web/src/lib/core/compress.ts +++ b/web/src/lib/core/compress.ts @@ -91,6 +91,12 @@ export type EncodeFn = ( quality?: number, ) => Promise; +export interface FitWithinBytesResult { + blob: Blob; + /** Size of the returned blob; > targetBytes means the limit was unreachable. */ + achievedBytes: number; +} + /** Encodes img to fit targetBytes (quality search for lossy, color count for * PNG; BMP as-is). `encodeFn` injected (core/io.encode): policy stays DOM-free. */ export async function fitWithinBytes( @@ -99,18 +105,24 @@ export async function fitWithinBytes( targetBytes: number, quality: number | undefined, encodeFn: EncodeFn, -): Promise { - if (mime === "image/bmp") return encodeFn(img, mime); +): Promise { + if (mime === "image/bmp") { + const blob = await encodeFn(img, mime); + return { blob, achievedBytes: blob.size }; + } const initial = await encodeFn(img, mime, quality); - if (initial.size <= targetBytes) return initial; + if (initial.size <= targetBytes) + return { blob: initial, achievedBytes: initial.size }; if (mime === "image/png") { const encodeSize = async (k: number) => (await encodeFn(quantizeImage(img, k).image, mime)).size; const k = await findMaxColorsWithin(targetBytes, 256, encodeSize); - return encodeFn(quantizeImage(img, k).image, mime); + const blob = await encodeFn(quantizeImage(img, k).image, mime); + return { blob, achievedBytes: blob.size }; } const encodeSize = async (q: number) => (await encodeFn(img, mime, q / 100)).size; const q = await findQualityWithin(targetBytes, encodeSize); - return encodeFn(img, mime, q / 100); + const blob = await encodeFn(img, mime, q / 100); + return { blob, achievedBytes: blob.size }; } diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index ce64d22..e592e07 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -67,6 +67,8 @@ export const en: Dict = { warnFirstOnly: "Step {n}: analyze works on one image — showing the first of {total}", warnSourceSkip: "{skipped} of {total} files could not be read", + warnSizeLimitMiss: + "Could not fit into {limit} KB — downloaded file is {actual} KB", }, savedChains: { pageTitle: "Saved pipelines — easy-png-tools", diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index ad4f709..a750fa9 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -69,6 +69,8 @@ export const ru: Dict = { warnFirstOnly: "Шаг {n}: анализ работает с одним изображением — показан первый из {total}", warnSourceSkip: "{skipped} из {total} файлов не удалось прочитать", + warnSizeLimitMiss: + "Не удалось ужаться до {limit} КБ — скачан файл на {actual} КБ", }, savedChains: { pageTitle: "Сохранённые пайплайны — easy-png-tools", diff --git a/web/src/lib/i18n/schema-tool-strings.ts b/web/src/lib/i18n/schema-tool-strings.ts index b4b3906..9bfd35c 100644 --- a/web/src/lib/i18n/schema-tool-strings.ts +++ b/web/src/lib/i18n/schema-tool-strings.ts @@ -80,6 +80,11 @@ export function chainWarningText(w: ChainWarning): string { skipped: w.skipped, total: w.total, }); + case "sizeLimitMiss": + return t("chain.warnSizeLimitMiss", { + limit: w.limitKb, + actual: w.actualKb, + }); } } diff --git a/web/src/lib/run-chain.ts b/web/src/lib/run-chain.ts index 3413f19..d026f45 100644 --- a/web/src/lib/run-chain.ts +++ b/web/src/lib/run-chain.ts @@ -17,7 +17,8 @@ export type ChainExecutor = ( export type ChainWarning = | { kind: "partial"; step: number; ok: number; total: number } | { kind: "firstOnly"; step: number; total: number } - | { kind: "sourceSkip"; skipped: number; total: number }; + | { kind: "sourceSkip"; skipped: number; total: number } + | { kind: "sizeLimitMiss"; limitKb: number; actualKb: number }; export class ChainStepError extends Error { constructor(