354 lines
9.6 KiB
TypeScript
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);
|
|
}
|
|
}
|