292 lines
7.4 KiB
TypeScript
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,
|
|
};
|