Makelore 2.0 initial clean snapshot
This commit is contained in:
353
src/lib/image-attachment-compression.ts
Normal file
353
src/lib/image-attachment-compression.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user