import sharp from 'sharp' import { sliceImage } from '@/lib/image-processor' import { configureSharpRuntime } from '@/lib/sharp-runtime' import { withImageProcessingSlot } from '@/lib/image-processing-limiter' import { BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES, BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES, BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT, } from '@/lib/brand-knowledge-file-types' import { BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT, planBrandKnowledgePreviewSlices, } from '@/lib/performance-survey-image-source-guard' configureSharpRuntime() export const BRAND_KNOWLEDGE_PREVIEW_MAX_WIDTH = 12_000 export const BRAND_KNOWLEDGE_PREVIEW_MAX_HEIGHT = 90_000 export const BRAND_KNOWLEDGE_PREVIEW_MAX_PIXELS = 60_000_000 export const BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT = 1_500 export const BRAND_KNOWLEDGE_PREVIEW_SLICE_MAX_KB = 500 export const BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH = 1_600 const FORMAT_DETAILS = { png: { extension: 'png', contentType: 'image/png' }, jpeg: { extension: 'jpg', contentType: 'image/jpeg' }, webp: { extension: 'webp', contentType: 'image/webp' }, } as const type SupportedPreviewFormat = keyof typeof FORMAT_DETAILS function normalizeContentType(value?: string | null) { return String(value || '').split(';', 1)[0].trim().toLowerCase() } export function assertBrandKnowledgePreviewMetadata( metadata: Pick, declaredContentType?: string | null, ) { const format = metadata.format as SupportedPreviewFormat | undefined const details = format ? FORMAT_DETAILS[format] : undefined if (!details) throw new Error('预览图只支持 PNG、JPEG 或 WebP') const width = metadata.width || 0 const height = metadata.height || 0 if (width < 1 || height < 1) throw new Error('无法获取预览图尺寸') if (width > BRAND_KNOWLEDGE_PREVIEW_MAX_WIDTH || height > BRAND_KNOWLEDGE_PREVIEW_MAX_HEIGHT) { throw new Error(`预览图尺寸过大,宽度需不超过 ${BRAND_KNOWLEDGE_PREVIEW_MAX_WIDTH}px,高度需不超过 ${BRAND_KNOWLEDGE_PREVIEW_MAX_HEIGHT}px`) } if (width * height > BRAND_KNOWLEDGE_PREVIEW_MAX_PIXELS) { throw new Error('预览图像素总量过大,需不超过 6000 万像素') } if (Number(metadata.pages || 1) > 1) throw new Error('预览图不支持动画或多页图片') const declared = normalizeContentType(declaredContentType) if (declared && declared !== details.contentType) throw new Error('预览图内容与声明格式不一致') return { ...details, width, height } } export async function prepareBrandKnowledgePreview(buffer: Buffer, declaredContentType?: string | null) { let metadata: sharp.Metadata try { metadata = await sharp(buffer, { animated: true, failOn: 'error', limitInputPixels: BRAND_KNOWLEDGE_PREVIEW_MAX_PIXELS, sequentialRead: true, }).metadata() } catch (error) { if (error instanceof Error && /pixel limit/i.test(error.message)) { throw new Error('预览图像素总量过大,需不超过 6000 万像素') } throw new Error('预览图无法解码,请上传完整的 PNG、JPEG 或 WebP 图片') } const source = assertBrandKnowledgePreviewMetadata(metadata, declaredContentType) const outputScale = Math.min(1, BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH / source.width) const outputHeight = Math.max(1, Math.round(source.height * outputScale)) const previewPlan = planBrandKnowledgePreviewSlices( source.height, outputHeight, BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT, ) let slices: Buffer[] try { slices = await sliceImage( buffer, previewPlan.sliceHeightLimit, BRAND_KNOWLEDGE_PREVIEW_SLICE_MAX_KB, BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH, { maxInputWidth: BRAND_KNOWLEDGE_PREVIEW_MAX_WIDTH, maxInputHeight: BRAND_KNOWLEDGE_PREVIEW_MAX_HEIGHT, maxInputPixels: BRAND_KNOWLEDGE_PREVIEW_MAX_PIXELS, }, ) } catch { throw new Error('预览图处理失败,请确认图片完整且未超过尺寸限制') } if (slices.length === 0) throw new Error('预览图处理后没有可用片段') return { source, slices } } export type BrandKnowledgeUploadedPreviewSlice = { buffer: Buffer declaredContentType?: string | null } export async function validateBrandKnowledgeUploadedPreviewSlices( uploadedSlices: BrandKnowledgeUploadedPreviewSlice[], ) { if (uploadedSlices.length < 1 || uploadedSlices.length > BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT) { throw new Error(`预览图切片数量需在 1 至 ${BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT} 片之间`) } const totalBytes = uploadedSlices.reduce((sum, slice) => sum + slice.buffer.length, 0) if (totalBytes > BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES) { throw new Error('预览图切片总大小不能超过 50MB') } return withImageProcessingSlot(async () => { const slices: Array<{ buffer: Buffer; width: number; height: number }> = [] let expectedWidth = 0 let expectedFullSliceHeight = 0 const maxUploadedSliceHeight = uploadedSlices.length === 1 ? BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT : BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT for (let index = 0; index < uploadedSlices.length; index += 1) { const uploaded = uploadedSlices[index] if (uploaded.buffer.length < 1 || uploaded.buffer.length > BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES) { throw new Error(`预览图第 ${index + 1} 片大小无效`) } if (normalizeContentType(uploaded.declaredContentType) !== 'image/webp') { throw new Error(`预览图第 ${index + 1} 片只支持 WebP 格式`) } let metadata: sharp.Metadata try { metadata = await sharp(uploaded.buffer, { animated: true, failOn: 'error', limitInputPixels: BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH * maxUploadedSliceHeight, sequentialRead: true, }).metadata() } catch { throw new Error(`预览图第 ${index + 1} 片无法解码`) } const width = metadata.width || 0 const height = metadata.height || 0 if (metadata.format !== 'webp' || Number(metadata.pages || 1) > 1) { throw new Error(`预览图第 ${index + 1} 片不是有效的静态 WebP 图片`) } if (width < 1 || width > BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH || height < 1 || height > maxUploadedSliceHeight) { throw new Error(`预览图第 ${index + 1} 片尺寸无效`) } if (index === 0) { expectedWidth = width expectedFullSliceHeight = height } if (width !== expectedWidth) throw new Error('预览图切片宽度不一致') if (uploadedSlices.length > 1) { const isLast = index === uploadedSlices.length - 1 const isForcedShortPair = expectedFullSliceHeight <= Math.ceil(BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT / 2) const hasValidForcedPairGeometry = uploadedSlices.length === 2 && (!isLast || height === expectedFullSliceHeight || height === expectedFullSliceHeight - 1) const hasValidStandardGeometry = expectedFullSliceHeight === BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT && (isLast ? height <= expectedFullSliceHeight : height === expectedFullSliceHeight) if ((isForcedShortPair && !hasValidForcedPairGeometry) || (!isForcedShortPair && !hasValidStandardGeometry)) { throw new Error('预览图切片高度不一致') } } slices.push({ buffer: uploaded.buffer, width, height }) } return { slices, totalBytes } }) }