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

354 lines
9.6 KiB
TypeScript

import { estimateImportedModelVisionTokens } from '../../shared/imported-model-profile';
import { NIANCODE_USER_MODEL_ACCOUNT_ID } from '../../shared/user-model-config';
import { browserImageCodec } from './browser-image-codec';
export const IMAGE_MAX_PIXELS = 2_097_152;
export const IMAGE_MAX_LONG_EDGE = 2048;
export const LOSSY_IMAGE_QUALITY = 0.85;
// Bound automatic browser decoding while always retaining the original file.
export const IMAGE_MAX_AUTO_PROCESS_SOURCE_BYTES = 32 * 1024 * 1024;
export interface ImageDimensions {
width: number;
height: number;
}
export interface ImageResizeTarget extends ImageDimensions {
scale: number;
resized: boolean;
}
export interface ImageEncodeCandidate {
mimeType: 'image/png' | 'image/jpeg' | 'image/webp';
quality?: number;
}
const INFERRED_IMAGE_MIME_TYPES: Readonly<Record<string, string>> = {
apng: 'image/png',
avif: 'image/avif',
bmp: 'image/bmp',
gif: 'image/gif',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
png: 'image/png',
svg: 'image/svg+xml',
webp: 'image/webp',
};
const IMAGE_FILE_EXTENSIONS: Readonly<Record<string, string>> = {
'image/avif': 'avif',
'image/bmp': 'bmp',
'image/gif': 'gif',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/svg+xml': 'svg',
'image/webp': 'webp',
};
export function calculateImageResizeTarget(
width: number,
height: number,
): ImageResizeTarget {
if (
!Number.isFinite(width)
|| !Number.isFinite(height)
|| width <= 0
|| height <= 0
) {
throw new RangeError('Image dimensions must be finite positive numbers');
}
const sourceWidth = Math.max(1, Math.floor(width));
const sourceHeight = Math.max(1, Math.floor(height));
const scale = Math.min(
1,
IMAGE_MAX_LONG_EDGE / Math.max(sourceWidth, sourceHeight),
Math.sqrt(IMAGE_MAX_PIXELS / (sourceWidth * sourceHeight)),
);
return {
width: Math.max(1, Math.floor(sourceWidth * scale)),
height: Math.max(1, Math.floor(sourceHeight * scale)),
scale,
resized: scale < 1,
};
}
export function getEffectiveImageMimeType(file: File): string {
const explicit = file.type.trim().toLowerCase();
if (explicit) return explicit;
const extension = file.name.split('.').at(-1)?.toLowerCase() ?? '';
return INFERRED_IMAGE_MIME_TYPES[extension] ?? 'application/octet-stream';
}
export function shouldSkipAutomaticImageProcessing(mimeType: string): boolean {
const normalized = mimeType.trim().toLowerCase();
return normalized === 'image/gif' || normalized === 'image/svg+xml';
}
export function getImageEncodeCandidates(
mimeType: string,
hasAlpha: boolean,
): ImageEncodeCandidate[] {
const normalized = mimeType.trim().toLowerCase();
if (normalized === 'image/png' || normalized === 'image/bmp') {
return [{ mimeType: 'image/png' }];
}
if (normalized === 'image/jpeg' || normalized === 'image/jpg') {
return [{ mimeType: 'image/jpeg', quality: LOSSY_IMAGE_QUALITY }];
}
if (normalized === 'image/webp' || normalized === 'image/avif') {
return [
{ mimeType: 'image/webp', quality: LOSSY_IMAGE_QUALITY },
hasAlpha
? { mimeType: 'image/png' }
: { mimeType: 'image/jpeg', quality: LOSSY_IMAGE_QUALITY },
];
}
return [
hasAlpha
? { mimeType: 'image/png' }
: { mimeType: 'image/jpeg', quality: LOSSY_IMAGE_QUALITY },
];
}
export function getImageFileNameForMime(
displayName: string,
mimeType: string,
): string {
const extension = IMAGE_FILE_EXTENSIONS[mimeType.trim().toLowerCase()];
if (!extension) return displayName;
const lastDot = displayName.lastIndexOf('.');
const stem = lastDot > 0 ? displayName.slice(0, lastDot) : displayName;
return `${stem}.${extension}`;
}
export function estimateSelectedImageTokens(
modelRef: string | null | undefined,
width: number,
height: number,
): number | null {
const normalized = modelRef?.trim() ?? '';
const separator = normalized.indexOf('/');
if (separator <= 0 || separator !== normalized.lastIndexOf('/')) return null;
const providerId = normalized.slice(0, separator);
const modelId = normalized.slice(separator + 1);
if (providerId !== NIANCODE_USER_MODEL_ACCOUNT_ID || !modelId) return null;
return estimateImportedModelVisionTokens(modelId, width, height);
}
export interface DecodedImage extends ImageDimensions {
source: CanvasImageSource;
dispose: () => void;
}
export interface RenderedImage {
canvas: HTMLCanvasElement;
hasAlpha: boolean;
dispose: () => void;
}
export interface BrowserImageCodec {
inspectFrameCount: (
blob: Blob,
mimeType: string,
signal?: AbortSignal,
) => Promise<number | null>;
decode: (
blob: Blob,
options: { imageOrientation: 'from-image' },
signal?: AbortSignal,
) => Promise<DecodedImage>;
render: (
decoded: DecodedImage,
target: ImageDimensions,
signal?: AbortSignal,
) => RenderedImage;
encode: (
rendered: RenderedImage,
candidate: ImageEncodeCandidate,
signal?: AbortSignal,
) => Promise<Blob | null>;
}
export type ImageProcessingStatus =
| 'unchanged'
| 'compressed'
| 'skipped'
| 'failed';
export type ImageSkipReason =
| 'animated'
| 'vector'
| 'static-unverified'
| 'source-too-large';
export interface ImageProcessingOptions {
signal?: AbortSignal;
}
export interface ProcessedImageAttachment {
status: ImageProcessingStatus;
original: {
blob: File;
mimeType: string;
bytes: number;
width?: number;
height?: number;
};
compressed?: {
blob: Blob;
mimeType: string;
bytes: number;
width: number;
height: number;
};
defaultVariant: 'original' | 'compressed';
skipReason?: ImageSkipReason;
warning?: string;
}
function disposeImageSafely(image: { dispose: () => void } | undefined): void {
try {
image?.dispose();
} catch {
// Preserve the selected image result when browser resource cleanup fails.
}
}
function throwIfAborted(signal: AbortSignal | undefined): void {
signal?.throwIfAborted();
}
function isAbortError(error: unknown): boolean {
return (
typeof error === 'object'
&& error !== null
&& 'name' in error
&& error.name === 'AbortError'
);
}
export async function processImageAttachment(
file: File,
codec: BrowserImageCodec = browserImageCodec,
options: ImageProcessingOptions = {},
): Promise<ProcessedImageAttachment> {
const { signal } = options;
const mimeType = getEffectiveImageMimeType(file);
const originalWithoutDimensions = {
blob: file,
mimeType,
bytes: file.size,
};
if (mimeType === 'image/gif') {
return {
status: 'skipped',
original: originalWithoutDimensions,
defaultVariant: 'original',
skipReason: 'animated',
warning: 'GIF 动画不会自动压缩,发送时将使用原图。',
};
}
if (mimeType === 'image/svg+xml') {
return {
status: 'skipped',
original: originalWithoutDimensions,
defaultVariant: 'original',
skipReason: 'vector',
warning: 'SVG 矢量图不会自动压缩,发送时将使用原图。',
};
}
if (file.size > IMAGE_MAX_AUTO_PROCESS_SOURCE_BYTES) {
return {
status: 'skipped',
original: originalWithoutDimensions,
defaultVariant: 'original',
skipReason: 'source-too-large',
warning: '图片超过 32 MB 自动处理上限,发送时将使用原图。',
};
}
let decoded: DecodedImage | undefined;
let rendered: RenderedImage | undefined;
let original: ProcessedImageAttachment['original'] = originalWithoutDimensions;
try {
throwIfAborted(signal);
const frameCount = await codec.inspectFrameCount(file, mimeType, signal);
throwIfAborted(signal);
if (frameCount !== 1) {
const animated = typeof frameCount === 'number' && frameCount > 1;
return {
status: 'skipped',
original: originalWithoutDimensions,
defaultVariant: 'original',
skipReason: animated ? 'animated' : 'static-unverified',
warning: animated
? '检测到动画图片,已保留原图。'
: '无法确认图片为单帧,已保留原图。',
};
}
decoded = await codec.decode(
file,
{ imageOrientation: 'from-image' },
signal,
);
throwIfAborted(signal);
const target = calculateImageResizeTarget(decoded.width, decoded.height);
original = {
...originalWithoutDimensions,
width: decoded.width,
height: decoded.height,
};
if (!target.resized) {
return {
status: 'unchanged',
original,
defaultVariant: 'original',
};
}
rendered = codec.render(
decoded,
{
width: target.width,
height: target.height,
},
signal,
);
throwIfAborted(signal);
for (const candidate of getImageEncodeCandidates(mimeType, rendered.hasAlpha)) {
throwIfAborted(signal);
const blob = await codec.encode(rendered, candidate, signal);
throwIfAborted(signal);
if (!blob) continue;
return {
status: 'compressed',
original,
compressed: {
blob,
mimeType: blob.type || candidate.mimeType,
bytes: blob.size,
width: target.width,
height: target.height,
},
defaultVariant: 'compressed',
};
}
throw new Error('No supported image encoder');
} catch (error) {
if (signal?.aborted || isAbortError(error)) throw error;
return {
status: 'failed',
original,
defaultVariant: 'original',
warning: '图片压缩失败,发送时将使用原图。',
};
} finally {
disposeImageSafely(rendered);
disposeImageSafely(decoded);
}
}