Files
makelore/src/lib/browser-image-codec.ts
2026-07-29 17:22:35 +08:00

292 lines
7.4 KiB
TypeScript

import type {
BrowserImageCodec,
DecodedImage,
ImageDimensions,
ImageEncodeCandidate,
RenderedImage,
} from './image-attachment-compression';
interface ImageDecoderTrackLike {
frameCount: number;
}
interface ImageDecoderLike {
tracks: {
ready: Promise<void>;
selectedTrack?: ImageDecoderTrackLike | null;
};
close: () => void;
}
interface ImageDecoderConstructorLike {
new (init: {
data: ArrayBuffer | ReadableStream<Uint8Array>;
type: string;
preferAnimation: boolean;
}): ImageDecoderLike;
isTypeSupported: (type: string) => Promise<boolean>;
}
function getImageDecoderConstructor(): ImageDecoderConstructorLike | undefined {
return (globalThis as typeof globalThis & {
ImageDecoder?: ImageDecoderConstructorLike;
}).ImageDecoder;
}
function createAbortError(): DOMException {
return new DOMException('Image processing was aborted', 'AbortError');
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) throw signal.reason ?? createAbortError();
}
async function hasStaticImageSignature(
blob: Blob,
mimeType: string,
signal?: AbortSignal,
): Promise<boolean> {
throwIfAborted(signal);
const bytes = new Uint8Array(await blob.slice(0, 3).arrayBuffer());
throwIfAborted(signal);
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') {
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
}
return bytes[0] === 0x42 && bytes[1] === 0x4d;
}
async function inspectFrameCount(
blob: Blob,
mimeType: string,
signal?: AbortSignal,
): Promise<number | null> {
throwIfAborted(signal);
const normalized = mimeType.trim().toLowerCase();
if (
normalized === 'image/jpeg'
|| normalized === 'image/jpg'
|| normalized === 'image/bmp'
) {
return await hasStaticImageSignature(blob, normalized, signal) ? 1 : null;
}
const ImageDecoderConstructor = getImageDecoderConstructor();
if (!ImageDecoderConstructor) return null;
if (!(await ImageDecoderConstructor.isTypeSupported(normalized))) return null;
throwIfAborted(signal);
let decoder: ImageDecoderLike;
try {
decoder = new ImageDecoderConstructor({
data: blob.stream(),
type: normalized,
preferAnimation: true,
});
} catch {
throwIfAborted(signal);
const data = await blob.arrayBuffer();
throwIfAborted(signal);
decoder = new ImageDecoderConstructor({
data,
type: normalized,
preferAnimation: true,
});
}
let closed = false;
const close = () => {
if (closed) return;
closed = true;
decoder.close();
};
const abort = () => close();
signal?.addEventListener('abort', abort, { once: true });
try {
await decoder.tracks.ready;
throwIfAborted(signal);
const frameCount = decoder.tracks.selectedTrack?.frameCount;
return Number.isInteger(frameCount) && (frameCount ?? 0) > 0
? frameCount ?? null
: null;
} finally {
signal?.removeEventListener('abort', abort);
close();
}
}
async function decodeWithImageElement(
blob: Blob,
signal?: AbortSignal,
): Promise<DecodedImage> {
throwIfAborted(signal);
const url = URL.createObjectURL(blob);
let revoked = false;
const revokeUrl = () => {
if (revoked) return;
revoked = true;
URL.revokeObjectURL(url);
};
try {
return await new Promise<DecodedImage>((resolve, reject) => {
const image = new Image();
let settled = false;
let abort = () => {};
const removeAbortListener = () => signal?.removeEventListener('abort', abort);
const dispose = () => {
try {
image.src = '';
} catch {
// Preserve fail-open processing when browser image cleanup fails.
} finally {
revokeUrl();
}
};
abort = () => {
if (settled) return;
settled = true;
dispose();
removeAbortListener();
reject(signal?.reason ?? createAbortError());
};
image.decoding = 'async';
image.onload = () => {
if (settled) return;
settled = true;
removeAbortListener();
resolve({
source: image,
width: image.naturalWidth,
height: image.naturalHeight,
dispose,
});
};
image.onerror = () => {
if (settled) return;
settled = true;
dispose();
removeAbortListener();
reject(new Error('Image element decode failed'));
};
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) {
abort();
return;
}
image.src = url;
});
} catch (error) {
revokeUrl();
throw error;
}
}
async function decode(
blob: Blob,
options: { imageOrientation: 'from-image' },
signal?: AbortSignal,
): Promise<DecodedImage> {
throwIfAborted(signal);
if (typeof globalThis.createImageBitmap !== 'function') {
return await decodeWithImageElement(blob, signal);
}
const bitmap = await globalThis.createImageBitmap(blob, {
imageOrientation: options.imageOrientation,
});
if (signal?.aborted) {
bitmap.close();
throw signal.reason ?? createAbortError();
}
return {
source: bitmap,
width: bitmap.width,
height: bitmap.height,
dispose: () => bitmap.close(),
};
}
function render(
decoded: DecodedImage,
target: ImageDimensions,
signal?: AbortSignal,
): RenderedImage {
throwIfAborted(signal);
const canvas = document.createElement('canvas');
canvas.width = target.width;
canvas.height = target.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
if (!context) throw new Error('2D canvas is unavailable');
context.clearRect(0, 0, target.width, target.height);
context.drawImage(decoded.source, 0, 0, target.width, target.height);
const pixels = context.getImageData(0, 0, target.width, target.height).data;
let hasAlpha = false;
for (let index = 3; index < pixels.length; index += 4) {
if (pixels[index] < 255) {
hasAlpha = true;
break;
}
}
const rendered = {
canvas,
hasAlpha,
dispose: () => {
canvas.width = 1;
canvas.height = 1;
},
};
try {
throwIfAborted(signal);
return rendered;
} catch (error) {
rendered.dispose();
throw error;
}
}
async function encode(
rendered: RenderedImage,
candidate: ImageEncodeCandidate,
signal?: AbortSignal,
): Promise<Blob | null> {
throwIfAborted(signal);
const blob = await new Promise<Blob | null>((resolve, reject) => {
let settled = false;
let abort = () => {};
const removeAbortListener = () => signal?.removeEventListener('abort', abort);
abort = () => {
if (settled) return;
settled = true;
removeAbortListener();
reject(signal?.reason ?? createAbortError());
};
signal?.addEventListener('abort', abort, { once: true });
try {
rendered.canvas.toBlob(
(result) => {
if (settled) return;
settled = true;
removeAbortListener();
resolve(result);
},
candidate.mimeType,
candidate.quality,
);
} catch (error) {
settled = true;
removeAbortListener();
reject(error);
}
});
throwIfAborted(signal);
if (!blob) return null;
return blob.type.trim().toLowerCase() === candidate.mimeType ? blob : null;
}
export const browserImageCodec: BrowserImageCodec = {
inspectFrameCount,
decode,
render,
encode,
};