mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 00:22:31 +00:00
265 lines
7.9 KiB
TypeScript
265 lines
7.9 KiB
TypeScript
/**
|
|
* Off-main-thread image encoder.
|
|
*
|
|
* Accepts a ``File``, validates it via magic bytes (ignoring the extension to
|
|
* defeat rename-based spoofs), and either passes through or *normalizes* the
|
|
* bytes so the resulting base64 data URL stays ≤ ``TARGET_MAX_BYTES``. The
|
|
* normalization path uses ``createImageBitmap`` + ``OffscreenCanvas`` so the
|
|
* full decode/resize/re-encode cycle never blocks the UI thread.
|
|
*
|
|
* Output contract:
|
|
* ``{ok: true, dataUrl, mime, bytes, origBytes, normalized}`` on success, or
|
|
* ``{ok: false, reason}`` for every recoverable failure — magic-bytes
|
|
* mismatch, unsupported MIME, decode error, or a post-normalization payload
|
|
* that *still* exceeds the budget (extreme aspect ratios).
|
|
*/
|
|
|
|
/// <reference lib="webworker" />
|
|
|
|
// --- Types -------------------------------------------------------------------
|
|
|
|
export type EncodeInput = {
|
|
id: string;
|
|
file: File;
|
|
};
|
|
|
|
export type EncodeSuccess = {
|
|
id: string;
|
|
ok: true;
|
|
dataUrl: string;
|
|
mime: string;
|
|
bytes: number;
|
|
origBytes: number;
|
|
/** True iff the Worker re-encoded the image to hit the size budget. */
|
|
normalized: boolean;
|
|
};
|
|
|
|
export type EncodeFailure = {
|
|
id: string;
|
|
ok: false;
|
|
reason:
|
|
| "invalid_mime"
|
|
| "magic_mismatch"
|
|
| "too_large_after_normalize"
|
|
| "decode_failed"
|
|
| "io";
|
|
};
|
|
|
|
export type EncodeResponse = EncodeSuccess | EncodeFailure;
|
|
|
|
// --- Budgets -----------------------------------------------------------------
|
|
|
|
/** Upper bound for the final base64-decoded payload. Matches the server-side
|
|
* safeguard (8 MB) minus safety margin; anything this function yields should
|
|
* safely pass ``_MAX_IMAGE_BYTES`` on the server. */
|
|
export const TARGET_MAX_BYTES = 6 * 1024 * 1024;
|
|
|
|
/** Long-edge pixel cap when we resize a large image. 2048 keeps retina UIs
|
|
* crisp while bounding decode cost and matching most LLM vision tiers'
|
|
* internal downscale target. */
|
|
const NORMALIZE_MAX_EDGE = 2048;
|
|
|
|
/** JPEG/WebP quality during normalization. 0.85 is the sweet spot — visually
|
|
* lossless for content photography, ~30% smaller than libjpeg default. */
|
|
const WEBP_QUALITY = 0.85;
|
|
|
|
/** PNG / GIF kept as PNG after normalization so crisp UI screenshots stay
|
|
* lossless. JPEG / WebP re-encode as WebP for better compression. */
|
|
const NORMALIZE_LOSSY_MIMES = new Set(["image/jpeg", "image/webp"]);
|
|
|
|
const SUPPORTED_MIMES = new Set([
|
|
"image/png",
|
|
"image/jpeg",
|
|
"image/webp",
|
|
"image/gif",
|
|
]);
|
|
|
|
// --- Magic bytes -------------------------------------------------------------
|
|
|
|
/** Sniff the first 12 bytes; returns the canonical MIME or ``null``.
|
|
*
|
|
* Covers PNG, JPEG, WebP, GIF — the same whitelist honoured by the server.
|
|
*/
|
|
export function sniffImageMime(bytes: Uint8Array): string | null {
|
|
if (bytes.length >= 8) {
|
|
if (
|
|
bytes[0] === 0x89 &&
|
|
bytes[1] === 0x50 &&
|
|
bytes[2] === 0x4e &&
|
|
bytes[3] === 0x47 &&
|
|
bytes[4] === 0x0d &&
|
|
bytes[5] === 0x0a &&
|
|
bytes[6] === 0x1a &&
|
|
bytes[7] === 0x0a
|
|
) {
|
|
return "image/png";
|
|
}
|
|
}
|
|
if (bytes.length >= 3) {
|
|
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
return "image/jpeg";
|
|
}
|
|
}
|
|
if (bytes.length >= 6) {
|
|
const g1 =
|
|
bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 &&
|
|
bytes[3] === 0x38 && bytes[5] === 0x61;
|
|
if (g1 && (bytes[4] === 0x37 || bytes[4] === 0x39)) {
|
|
return "image/gif";
|
|
}
|
|
}
|
|
if (bytes.length >= 12) {
|
|
const riff =
|
|
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46;
|
|
const webp =
|
|
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
|
|
if (riff && webp) return "image/webp";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// --- Encoder -----------------------------------------------------------------
|
|
|
|
function bufferToBase64(buf: ArrayBuffer): string {
|
|
// ``btoa`` can't take large strings — chunk through 32 KB windows.
|
|
const bytes = new Uint8Array(buf);
|
|
let binary = "";
|
|
const CHUNK = 0x8000;
|
|
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
binary += String.fromCharCode.apply(
|
|
null,
|
|
bytes.subarray(i, i + CHUNK) as unknown as number[],
|
|
);
|
|
}
|
|
return self.btoa(binary);
|
|
}
|
|
|
|
function computeScaledDims(
|
|
srcW: number,
|
|
srcH: number,
|
|
maxEdge: number,
|
|
): { w: number; h: number } {
|
|
const longest = Math.max(srcW, srcH);
|
|
if (longest <= maxEdge) return { w: srcW, h: srcH };
|
|
const scale = maxEdge / longest;
|
|
return {
|
|
w: Math.max(1, Math.round(srcW * scale)),
|
|
h: Math.max(1, Math.round(srcH * scale)),
|
|
};
|
|
}
|
|
|
|
async function normalize(
|
|
file: File,
|
|
sourceMime: string,
|
|
): Promise<{ dataUrl: string; mime: string; bytes: number } | { error: EncodeFailure["reason"] }> {
|
|
// Re-encode paths: JPEG/WebP → WebP q=0.85; PNG/GIF → PNG (keep crisp).
|
|
const targetMime = NORMALIZE_LOSSY_MIMES.has(sourceMime)
|
|
? "image/webp"
|
|
: "image/png";
|
|
let bitmap: ImageBitmap;
|
|
try {
|
|
bitmap = await createImageBitmap(file);
|
|
} catch {
|
|
return { error: "decode_failed" };
|
|
}
|
|
const { w, h } = computeScaledDims(bitmap.width, bitmap.height, NORMALIZE_MAX_EDGE);
|
|
try {
|
|
const canvas = new OffscreenCanvas(w, h);
|
|
const ctx = canvas.getContext("2d", { alpha: true });
|
|
if (!ctx) {
|
|
bitmap.close();
|
|
return { error: "decode_failed" };
|
|
}
|
|
ctx.imageSmoothingQuality = "high";
|
|
ctx.drawImage(bitmap, 0, 0, w, h);
|
|
bitmap.close();
|
|
const options: ImageEncodeOptions = { type: targetMime };
|
|
if (targetMime === "image/webp") options.quality = WEBP_QUALITY;
|
|
const blob = await canvas.convertToBlob(options);
|
|
if (blob.size > TARGET_MAX_BYTES) {
|
|
return { error: "too_large_after_normalize" };
|
|
}
|
|
const buf = await blob.arrayBuffer();
|
|
const dataUrl = `data:${targetMime};base64,${bufferToBase64(buf)}`;
|
|
return { dataUrl, mime: targetMime, bytes: blob.size };
|
|
} catch {
|
|
try {
|
|
bitmap.close();
|
|
} catch {
|
|
// bitmap already closed
|
|
}
|
|
return { error: "decode_failed" };
|
|
}
|
|
}
|
|
|
|
export async function encodeImageInWorker(
|
|
input: EncodeInput,
|
|
): Promise<EncodeResponse> {
|
|
const { id, file } = input;
|
|
const origBytes = file.size;
|
|
|
|
let buffer: ArrayBuffer;
|
|
try {
|
|
buffer = await file.arrayBuffer();
|
|
} catch {
|
|
return { id, ok: false, reason: "io" };
|
|
}
|
|
|
|
const head = new Uint8Array(buffer.slice(0, 12));
|
|
const sniffed = sniffImageMime(head);
|
|
if (!sniffed) return { id, ok: false, reason: "magic_mismatch" };
|
|
if (!SUPPORTED_MIMES.has(sniffed)) {
|
|
return { id, ok: false, reason: "invalid_mime" };
|
|
}
|
|
// Defend against MIME spoofing: the declared ``file.type`` can lie.
|
|
if (file.type && SUPPORTED_MIMES.has(file.type) && file.type !== sniffed) {
|
|
// Trust the magic bytes; proceed with the sniffed MIME.
|
|
}
|
|
|
|
if (origBytes <= TARGET_MAX_BYTES) {
|
|
const dataUrl = `data:${sniffed};base64,${bufferToBase64(buffer)}`;
|
|
return {
|
|
id,
|
|
ok: true,
|
|
dataUrl,
|
|
mime: sniffed,
|
|
bytes: origBytes,
|
|
origBytes,
|
|
normalized: false,
|
|
};
|
|
}
|
|
|
|
const result = await normalize(file, sniffed);
|
|
if ("error" in result) {
|
|
return { id, ok: false, reason: result.error };
|
|
}
|
|
return {
|
|
id,
|
|
ok: true,
|
|
dataUrl: result.dataUrl,
|
|
mime: result.mime,
|
|
bytes: result.bytes,
|
|
origBytes,
|
|
normalized: true,
|
|
};
|
|
}
|
|
|
|
// --- Worker boot -------------------------------------------------------------
|
|
// Only attach the message listener when running *inside* a Worker so the same
|
|
// module can be imported by tests (and by the thin ``imageEncode.ts`` wrapper
|
|
// in the main thread, which also calls ``encodeImageInWorker`` as a
|
|
// fall-through path when the Worker isn't available).
|
|
|
|
declare const self: DedicatedWorkerGlobalScope;
|
|
|
|
if (
|
|
typeof self !== "undefined" &&
|
|
typeof (self as unknown as { importScripts?: unknown }).importScripts ===
|
|
"function"
|
|
) {
|
|
self.addEventListener("message", async (event: MessageEvent<EncodeInput>) => {
|
|
const response = await encodeImageInWorker(event.data);
|
|
self.postMessage(response);
|
|
});
|
|
}
|