mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-14 17:38:19 +00:00
129 lines
3.6 KiB
TypeScript
129 lines
3.6 KiB
TypeScript
import type { OutputMime } from "./io";
|
|
import { quantizeImage } from "./quantize";
|
|
import type { PixelImage } from "./types";
|
|
|
|
/** Compression presets: level -> number of quantization colors. */
|
|
export const COMPRESSION_LEVELS = {
|
|
light: 192,
|
|
balanced: 96,
|
|
strong: 44,
|
|
extreme: 16,
|
|
} as const;
|
|
|
|
export type CompressionLevel = keyof typeof COMPRESSION_LEVELS;
|
|
|
|
/**
|
|
* Binary search for the largest k in [2..maxK] whose encoded size fits
|
|
* targetBytes. If even k=2 does not fit, returns 2 (best effort).
|
|
* encodeSize may return null (encode error), treated as "does not fit".
|
|
*/
|
|
export async function findMaxColorsWithin(
|
|
targetBytes: number,
|
|
maxK: number,
|
|
encodeSize: (k: number) => Promise<number | null>,
|
|
): Promise<number> {
|
|
const hi = Math.max(2, Math.round(maxK));
|
|
let ok = 2;
|
|
let fitsAtTwo = false;
|
|
const probe = async (k: number): Promise<boolean> => {
|
|
const size = await encodeSize(Math.max(2, Math.min(hi, k)));
|
|
if (size === null) return false;
|
|
if (size <= targetBytes) {
|
|
ok = k;
|
|
fitsAtTwo = fitsAtTwo || k === 2;
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (!(await probe(2))) return 2;
|
|
let lo = 2;
|
|
let hiK = hi;
|
|
while (hiK - lo > 1) {
|
|
const mid = Math.floor((lo + hiK) / 2);
|
|
if (await probe(mid)) lo = mid;
|
|
else hiK = mid;
|
|
}
|
|
await probe(lo);
|
|
void fitsAtTwo;
|
|
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;
|
|
}
|
|
|
|
export type EncodeFn = (
|
|
img: PixelImage,
|
|
mime: OutputMime,
|
|
quality?: number,
|
|
) => 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
|
|
* PNG; BMP as-is). `encodeFn` injected (core/io.encode): policy stays DOM-free. */
|
|
export async function fitWithinBytes(
|
|
img: PixelImage,
|
|
mime: OutputMime,
|
|
targetBytes: number,
|
|
quality: number | undefined,
|
|
encodeFn: EncodeFn,
|
|
): Promise<FitWithinBytesResult> {
|
|
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 { 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);
|
|
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);
|
|
const blob = await encodeFn(img, mime, q / 100);
|
|
return { blob, achievedBytes: blob.size };
|
|
}
|