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