feat: add download size limit warning

This commit is contained in:
2026-10-11 10:31:50 +05:00
parent 81612db1f3
commit f67ddb3b0a
11 changed files with 111 additions and 29 deletions
-10
View File
@@ -10,12 +10,6 @@
## Pipeline ## 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», - [ ] названия инструментов — в pipeline инструмент называется «обрезать png»,
но он работает с любыми картинками, наверное надо убрать; в заголовке но он работает с любыми картинками, наверное надо убрать; в заголовке
страницы — возможно, надо указать все форматы (связано с SEO) страницы — возможно, надо указать все форматы (связано с SEO)
@@ -74,10 +68,6 @@
захотеть сохранить обработанный PNG или JPEG с другим качеством — блок захотеть сохранить обработанный PNG или JPEG с другим качеством — блок
мешает легальному сценарию. Решение не принято; перед реализацией мешает легальному сценарию. Решение не принято; перед реализацией
переобдумать. переобдумать.
- [ ] **Лимит КБ: сигнал о недостижимости** — если даже минимальное качество /
k=2 не влезает в лимит, файл скачивается молча с превышением. Нужно:
`fitWithinBytes` (`web/src/lib/core/io.ts`) возвращает также достигнутый
размер, UI показывает warning «не удалось ужаться до N КБ, получилось M».
- [ ] **Лимит КБ: индикация активного сжатия** — сейчас заданный лимит никак не - [ ] **Лимит КБ: индикация активного сжатия** — сейчас заданный лимит никак не
виден, пока меню Download закрыто: пользователь может забыть про него и виден, пока меню Download закрыто: пользователь может забыть про него и
случайно сжать следующие файлы. Нужен постоянный индикатор (значок/ случайно сжать следующие файлы. Нужен постоянный индикатор (значок/
@@ -15,6 +15,9 @@
dims?: string; dims?: string;
note?: string; note?: string;
noteTone?: "warning" | "info"; 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; actions?: Snippet;
} }
let { let {
@@ -27,6 +30,7 @@
dims = undefined, dims = undefined,
note = undefined, note = undefined,
noteTone = "warning", noteTone = "warning",
warnings = [],
actions = undefined, actions = undefined,
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -52,6 +56,9 @@
{#if note} {#if note}
<Badge tone={noteTone} label={note} /> <Badge tone={noteTone} label={note} />
{/if} {/if}
{#each warnings as warning (warning)}
<Badge tone="warning" label={warning} role="status" />
{/each}
</span> </span>
</figcaption> </figcaption>
<div class="canvas" class:checker={mode === "image"}> <div class="canvas" class:checker={mode === "image"}>
@@ -13,6 +13,7 @@
resultKind?: ResultKind; resultKind?: ResultKind;
result: PixelImage | null; result: PixelImage | null;
resultNote?: ResultNote | null; resultNote?: ResultNote | null;
warnings?: string[];
fileResult?: FileResult | null; fileResult?: FileResult | null;
textResult?: string | null; textResult?: string | null;
textVars?: Record<string, string | number>; textVars?: Record<string, string | number>;
@@ -28,6 +29,7 @@
resultKind = "image", resultKind = "image",
result, result,
resultNote = null, resultNote = null,
warnings = [],
fileResult = null, fileResult = null,
textResult = null, textResult = null,
textVars = undefined, textVars = undefined,
@@ -68,6 +70,7 @@
dims={resultDims} dims={resultDims}
note={resultNote ? t(resultNote.key) : undefined} note={resultNote ? t(resultNote.key) : undefined}
noteTone={resultNote?.tone ?? "warning"} noteTone={resultNote?.tone ?? "warning"}
{warnings}
> >
{#snippet actions()} {#snippet actions()}
{#if ontogglemask} {#if ontogglemask}
@@ -123,7 +123,11 @@
const stepFileSets = $derived(runner.stepFileSets); const stepFileSets = $derived(runner.stepFileSets);
const runWarnings = $derived(runner.runWarnings); const runWarnings = $derived(runner.runWarnings);
const displayError = $derived(runner.displayError); const displayError = $derived(runner.displayError);
const allWarnings = $derived([...upload.sourceWarnings, ...runWarnings]); const warningTexts = $derived(
[...upload.sourceWarnings, ...runWarnings, ...runner.downloadWarnings].map(
chainWarningText,
),
);
const masks = createStepMasks({ const masks = createStepMasks({
steps: () => steps, steps: () => steps,
@@ -316,6 +320,7 @@
resultKind={displayResultKind} resultKind={displayResultKind}
result={out} result={out}
{resultNote} {resultNote}
warnings={warningTexts}
{fileResult} {fileResult}
{textResult} {textResult}
textVars={verdictVars} textVars={verdictVars}
@@ -440,9 +445,6 @@
{#if errorText} {#if errorText}
<p class="error" role="alert">{errorText}</p> <p class="error" role="alert">{errorText}</p>
{/if} {/if}
{#each allWarnings as w (chainWarningText(w))}
<p class="warn" role="status">{chainWarningText(w)}</p>
{/each}
<StepReorderOverlay {steps} dnd={stepDnd} /> <StepReorderOverlay {steps} dnd={stepDnd} />
</DragDropProvider> </DragDropProvider>
</div> </div>
@@ -620,11 +622,6 @@
.no-schema { .no-schema {
font: var(--font-size-s) var(--font-mono); 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) { @media (--bp-tablet) {
.workspace, .workspace,
.workspace.aligned { .workspace.aligned {
@@ -56,6 +56,8 @@ interface RunnerState {
// Full named set per step (batch/fan-out); intermediate tiles show a grid. // Full named set per step (batch/fan-out); intermediate tiles show a grid.
stepFileSets: ToolImageFile[][]; stepFileSets: ToolImageFile[][];
runWarnings: ChainWarning[]; runWarnings: ChainWarning[];
// Size-limit feedback from the last download (limit unreachable etc.).
downloadWarnings: ChainWarning[];
displayError: DisplayError | null; displayError: DisplayError | null;
} }
@@ -153,10 +155,29 @@ async function download(s: RunnerState, ctx: SchemaToolRunnerCtx) {
const q = ctx.quality(); const q = ctx.quality();
const limit = ctx.limitKb(); const limit = ctx.limitKb();
const quality = q !== undefined ? q / 100 : undefined; const quality = q !== undefined ? q / 100 : undefined;
const blob = limit s.downloadWarnings = [];
? await fitWithinBytes(s.result, out.mime, limit * 1024, quality, encode) if (limit) {
: await encode(s.result, out.mime, quality); 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}`); downloadBlob(blob, `${ctx.pageSlug()}.${out.ext}`);
}
} catch (e) { } catch (e) {
s.displayError = toDisplayError(e); s.displayError = toDisplayError(e);
} finally { } finally {
@@ -193,6 +214,7 @@ function clearResults(s: RunnerState) {
s.fileResult = null; s.fileResult = null;
s.textResult = null; s.textResult = null;
s.verdictVars = undefined; s.verdictVars = undefined;
s.downloadWarnings = [];
s.displayError = null; s.displayError = null;
} }
@@ -211,6 +233,7 @@ export function createSchemaToolRunner(ctx: SchemaToolRunnerCtx) {
stepResults: [], stepResults: [],
stepFileSets: [], stepFileSets: [],
runWarnings: [], runWarnings: [],
downloadWarnings: [],
displayError: null, displayError: null,
}); });
return { return {
@@ -244,6 +267,9 @@ export function createSchemaToolRunner(ctx: SchemaToolRunnerCtx) {
get runWarnings() { get runWarnings() {
return s.runWarnings; return s.runWarnings;
}, },
get downloadWarnings() {
return s.downloadWarnings;
},
get displayError() { get displayError() {
return s.displayError; return s.displayError;
}, },
+37
View File
@@ -3,7 +3,16 @@ import {
COMPRESSION_LEVELS, COMPRESSION_LEVELS,
findMaxColorsWithin, findMaxColorsWithin,
findQualityWithin, findQualityWithin,
fitWithinBytes,
} from "./compress"; } 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", () => { describe("COMPRESSION_LEVELS", () => {
it("presets ordered by descending colors", () => { it("presets ordered by descending colors", () => {
@@ -77,3 +86,31 @@ describe("findQualityWithin", () => {
expect(calls).toBeLessThanOrEqual(10); // log2(99)+2 probes 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);
});
});
+17 -5
View File
@@ -91,6 +91,12 @@ export type EncodeFn = (
quality?: number, quality?: number,
) => Promise<Blob>; ) => Promise<Blob>;
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 /** 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. */ * PNG; BMP as-is). `encodeFn` injected (core/io.encode): policy stays DOM-free. */
export async function fitWithinBytes( export async function fitWithinBytes(
@@ -99,18 +105,24 @@ export async function fitWithinBytes(
targetBytes: number, targetBytes: number,
quality: number | undefined, quality: number | undefined,
encodeFn: EncodeFn, encodeFn: EncodeFn,
): Promise<Blob> { ): Promise<FitWithinBytesResult> {
if (mime === "image/bmp") return encodeFn(img, mime); if (mime === "image/bmp") {
const blob = await encodeFn(img, mime);
return { blob, achievedBytes: blob.size };
}
const initial = await encodeFn(img, mime, quality); 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") { if (mime === "image/png") {
const encodeSize = async (k: number) => const encodeSize = async (k: number) =>
(await encodeFn(quantizeImage(img, k).image, mime)).size; (await encodeFn(quantizeImage(img, k).image, mime)).size;
const k = await findMaxColorsWithin(targetBytes, 256, encodeSize); 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) => const encodeSize = async (q: number) =>
(await encodeFn(img, mime, q / 100)).size; (await encodeFn(img, mime, q / 100)).size;
const q = await findQualityWithin(targetBytes, encodeSize); 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 };
} }
+2
View File
@@ -67,6 +67,8 @@ export const en: Dict = {
warnFirstOnly: warnFirstOnly:
"Step {n}: analyze works on one image — showing the first of {total}", "Step {n}: analyze works on one image — showing the first of {total}",
warnSourceSkip: "{skipped} of {total} files could not be read", warnSourceSkip: "{skipped} of {total} files could not be read",
warnSizeLimitMiss:
"Could not fit into {limit} KB — downloaded file is {actual} KB",
}, },
savedChains: { savedChains: {
pageTitle: "Saved pipelines — easy-png-tools", pageTitle: "Saved pipelines — easy-png-tools",
+2
View File
@@ -69,6 +69,8 @@ export const ru: Dict = {
warnFirstOnly: warnFirstOnly:
"Шаг {n}: анализ работает с одним изображением — показан первый из {total}", "Шаг {n}: анализ работает с одним изображением — показан первый из {total}",
warnSourceSkip: "{skipped} из {total} файлов не удалось прочитать", warnSourceSkip: "{skipped} из {total} файлов не удалось прочитать",
warnSizeLimitMiss:
"Не удалось ужаться до {limit} КБ — скачан файл на {actual} КБ",
}, },
savedChains: { savedChains: {
pageTitle: "Сохранённые пайплайны — easy-png-tools", pageTitle: "Сохранённые пайплайны — easy-png-tools",
+5
View File
@@ -80,6 +80,11 @@ export function chainWarningText(w: ChainWarning): string {
skipped: w.skipped, skipped: w.skipped,
total: w.total, total: w.total,
}); });
case "sizeLimitMiss":
return t("chain.warnSizeLimitMiss", {
limit: w.limitKb,
actual: w.actualKb,
});
} }
} }
+2 -1
View File
@@ -17,7 +17,8 @@ export type ChainExecutor = (
export type ChainWarning = export type ChainWarning =
| { kind: "partial"; step: number; ok: number; total: number } | { kind: "partial"; step: number; ok: number; total: number }
| { kind: "firstOnly"; step: 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 { export class ChainStepError extends Error {
constructor( constructor(