183 lines
7.4 KiB
TypeScript
183 lines
7.4 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'
|
||
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<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)
|
||
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 }
|
||
})
|
||
}
|