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> = { 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> = { '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; decode: ( blob: Blob, options: { imageOrientation: 'from-image' }, signal?: AbortSignal, ) => Promise; render: ( decoded: DecodedImage, target: ImageDimensions, signal?: AbortSignal, ) => RenderedImage; encode: ( rendered: RenderedImage, candidate: ImageEncodeCandidate, signal?: AbortSignal, ) => Promise; } 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 { 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); } }