156 lines
6.2 KiB
TypeScript
156 lines
6.2 KiB
TypeScript
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'
|
||
|
||
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<sharp.Metadata, 'format' | 'width' | 'height' | 'pages'>,
|
||
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)
|
||
let slices: Buffer[]
|
||
try {
|
||
slices = await sliceImage(
|
||
buffer,
|
||
BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT,
|
||
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
|
||
|
||
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 * BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT,
|
||
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 > BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT) {
|
||
throw new Error(`预览图第 ${index + 1} 片尺寸无效`)
|
||
}
|
||
if (index === 0) expectedWidth = width
|
||
if (width !== expectedWidth) throw new Error('预览图切片宽度不一致')
|
||
if (index < uploadedSlices.length - 1 && height !== BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT) {
|
||
throw new Error(`预览图第 ${index + 1} 片高度应为 ${BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT}px`)
|
||
}
|
||
slices.push({ buffer: uploaded.buffer, width, height })
|
||
}
|
||
|
||
return { slices, totalBytes }
|
||
})
|
||
}
|