431 lines
15 KiB
TypeScript
431 lines
15 KiB
TypeScript
import sharp from 'sharp'
|
||
|
||
export const PERFORMANCE_SURVEY_IMAGE_LIMITS = {
|
||
maxSourceCount: 60,
|
||
maxSourceBytes: 200 * 1024 * 1024,
|
||
maxLegacyBatchSourceBytes: 60 * 1024 * 1024,
|
||
maxRequestBytes: 64 * 1024 * 1024,
|
||
maxPixelsPerImage: 60_000_000,
|
||
maxWidth: 12_000,
|
||
maxOutputWidth: 1_600,
|
||
sliceHeight: 1_500,
|
||
maxSlicesPerImage: 48,
|
||
maxSlicesPerBatch: 2_880,
|
||
targetSliceSizeKB: 500,
|
||
previewWidth: 720,
|
||
previewHeight: 560,
|
||
maxQueuedJobs: 3,
|
||
} as const
|
||
|
||
export type PerformanceSurveyImageInput = {
|
||
buffer: Buffer
|
||
mimeType: string
|
||
sourceName: string
|
||
}
|
||
|
||
export type PerformanceSurveyImagePlan = {
|
||
sourceIndex: number
|
||
sourceName: string
|
||
sourceWidth: number
|
||
sourceHeight: number
|
||
outputWidth: number
|
||
outputHeight: number
|
||
scale: number
|
||
sliceCount: number
|
||
}
|
||
|
||
export type ProcessedPerformanceSurveyImage = {
|
||
buffer: Buffer
|
||
sourceIndex: number
|
||
sliceIndex: number
|
||
sliceCount: number
|
||
sourceName: string
|
||
width: number
|
||
height: number
|
||
}
|
||
|
||
export type ProcessedPerformanceSurveyImageSource = {
|
||
plan: PerformanceSurveyImagePlan
|
||
preview: {
|
||
buffer: Buffer
|
||
width: number
|
||
height: number
|
||
}
|
||
slices: ProcessedPerformanceSurveyImage[]
|
||
}
|
||
|
||
export type PerformanceSurveyStoredImageSlice = {
|
||
url: string
|
||
width: number
|
||
height: number
|
||
}
|
||
|
||
export type PerformanceSurveyStoredImageGroup = {
|
||
version: 1
|
||
sourceName: string
|
||
sourceWidth: number
|
||
sourceHeight: number
|
||
width: number
|
||
height: number
|
||
previewUrl: string
|
||
previewWidth: number
|
||
previewHeight: number
|
||
slices: PerformanceSurveyStoredImageSlice[]
|
||
}
|
||
|
||
export type PerformanceSurveyImagePayload = {
|
||
imageUrls: string[]
|
||
imageGroups: PerformanceSurveyStoredImageGroup[]
|
||
}
|
||
|
||
export class PerformanceSurveyImageProcessingError extends Error {}
|
||
|
||
let performanceSurveyImageJobActive = false
|
||
const performanceSurveyImageJobQueue: Array<() => void> = []
|
||
|
||
async function acquirePerformanceSurveyImageJob() {
|
||
if (!performanceSurveyImageJobActive) {
|
||
performanceSurveyImageJobActive = true
|
||
return
|
||
}
|
||
if (performanceSurveyImageJobQueue.length >= PERFORMANCE_SURVEY_IMAGE_LIMITS.maxQueuedJobs) {
|
||
throw new PerformanceSurveyImageProcessingError('当前图片处理任务较多,请稍后重试')
|
||
}
|
||
await new Promise<void>((resolve) => performanceSurveyImageJobQueue.push(resolve))
|
||
}
|
||
|
||
function releasePerformanceSurveyImageJob() {
|
||
const next = performanceSurveyImageJobQueue.shift()
|
||
if (next) {
|
||
next()
|
||
return
|
||
}
|
||
performanceSurveyImageJobActive = false
|
||
}
|
||
|
||
export async function withPerformanceSurveyImageProcessingSlot<T>(task: () => Promise<T>): Promise<T> {
|
||
await acquirePerformanceSurveyImageJob()
|
||
try {
|
||
return await task()
|
||
} finally {
|
||
releasePerformanceSurveyImageJob()
|
||
}
|
||
}
|
||
|
||
export function assertPerformanceSurveyImageRequestSize(contentLength: string | null) {
|
||
if (!contentLength) return
|
||
const bytes = Number(contentLength)
|
||
if (!Number.isFinite(bytes) || bytes <= 0) return
|
||
if (bytes > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxRequestBytes) {
|
||
throw new PerformanceSurveyImageProcessingError('旧版直传请求总大小不能超过 60MB,请刷新页面后重试')
|
||
}
|
||
}
|
||
|
||
export function getPerformanceSurveyImageSliceCount(height: number) {
|
||
return Math.ceil(height / PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight)
|
||
}
|
||
|
||
export function assertPerformanceSurveyImageDimensions(width: number, height: number, sourceName = '图片') {
|
||
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
|
||
throw new PerformanceSurveyImageProcessingError(`图片「${sourceName}」尺寸无效`)
|
||
}
|
||
if (width > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxWidth) {
|
||
throw new PerformanceSurveyImageProcessingError(
|
||
`图片「${sourceName}」宽度超过 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxWidth}px`,
|
||
)
|
||
}
|
||
if (width * height > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxPixelsPerImage) {
|
||
throw new PerformanceSurveyImageProcessingError(`图片「${sourceName}」像素总量过大`)
|
||
}
|
||
|
||
const sliceCount = getPerformanceSurveyImageSliceCount(height)
|
||
if (sliceCount > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerImage) {
|
||
throw new PerformanceSurveyImageProcessingError(
|
||
`图片「${sourceName}」过长,最多允许 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerImage} 个切片`,
|
||
)
|
||
}
|
||
return sliceCount
|
||
}
|
||
|
||
export async function inspectPerformanceSurveyImage(
|
||
input: PerformanceSurveyImageInput,
|
||
sourceIndex = 0,
|
||
): Promise<PerformanceSurveyImagePlan> {
|
||
if (!input.mimeType.startsWith('image/')) {
|
||
throw new PerformanceSurveyImageProcessingError(`文件「${input.sourceName}」不是图片`)
|
||
}
|
||
if (input.buffer.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceBytes) {
|
||
throw new PerformanceSurveyImageProcessingError(`图片「${input.sourceName}」超过 200MB`)
|
||
}
|
||
|
||
try {
|
||
const metadata = await sharp(input.buffer, {
|
||
limitInputPixels: PERFORMANCE_SURVEY_IMAGE_LIMITS.maxPixelsPerImage,
|
||
sequentialRead: true,
|
||
failOn: 'error',
|
||
}).metadata()
|
||
if (!metadata.width || !metadata.height) {
|
||
throw new PerformanceSurveyImageProcessingError(`图片「${input.sourceName}」尺寸无效`)
|
||
}
|
||
if (Number(metadata.pages || 1) > 1) {
|
||
throw new PerformanceSurveyImageProcessingError(`图片「${input.sourceName}」不支持动图或多页图片`)
|
||
}
|
||
|
||
assertPerformanceSurveyImageDimensions(metadata.width, metadata.height, input.sourceName)
|
||
const scale = Math.min(1, PERFORMANCE_SURVEY_IMAGE_LIMITS.maxOutputWidth / metadata.width)
|
||
const outputWidth = Math.max(1, Math.round(metadata.width * scale))
|
||
const outputHeight = Math.max(1, Math.round(metadata.height * scale))
|
||
const sliceCount = getPerformanceSurveyImageSliceCount(outputHeight)
|
||
if (sliceCount > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerImage) {
|
||
throw new PerformanceSurveyImageProcessingError(
|
||
`图片「${input.sourceName}」过长,最多允许 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerImage} 个切片`,
|
||
)
|
||
}
|
||
|
||
return {
|
||
sourceIndex,
|
||
sourceName: input.sourceName,
|
||
sourceWidth: metadata.width,
|
||
sourceHeight: metadata.height,
|
||
outputWidth,
|
||
outputHeight,
|
||
scale,
|
||
sliceCount,
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof PerformanceSurveyImageProcessingError) throw error
|
||
throw new PerformanceSurveyImageProcessingError(`图片「${input.sourceName}」无法读取或像素总量过大`)
|
||
}
|
||
}
|
||
|
||
export function assertPerformanceSurveyImageBatch(plans: PerformanceSurveyImagePlan[]) {
|
||
const sliceCount = plans.reduce((sum, plan) => sum + plan.sliceCount, 0)
|
||
if (sliceCount > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerBatch) {
|
||
throw new PerformanceSurveyImageProcessingError(
|
||
`单个案例处理后最多生成 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerBatch} 个切片`,
|
||
)
|
||
}
|
||
}
|
||
|
||
async function encodePerformanceSurveySlice(
|
||
input: PerformanceSurveyImageInput,
|
||
plan: PerformanceSurveyImagePlan,
|
||
sliceIndex: number,
|
||
): Promise<ProcessedPerformanceSurveyImage> {
|
||
const outputTop = sliceIndex * PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight
|
||
const outputHeight = Math.min(
|
||
PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight,
|
||
plan.outputHeight - outputTop,
|
||
)
|
||
const sourceTop = Math.floor(outputTop / plan.scale)
|
||
const sourceBottom = sliceIndex === plan.sliceCount - 1
|
||
? plan.sourceHeight
|
||
: Math.min(plan.sourceHeight, Math.ceil((outputTop + outputHeight) / plan.scale))
|
||
const sourceHeight = Math.max(1, sourceBottom - sourceTop)
|
||
|
||
const raw = await sharp(input.buffer, {
|
||
limitInputPixels: PERFORMANCE_SURVEY_IMAGE_LIMITS.maxPixelsPerImage,
|
||
sequentialRead: true,
|
||
failOn: 'error',
|
||
})
|
||
.extract({ left: 0, top: sourceTop, width: plan.sourceWidth, height: sourceHeight })
|
||
.resize({ width: plan.outputWidth, height: outputHeight, fit: 'fill' })
|
||
.raw()
|
||
.toBuffer({ resolveWithObject: true })
|
||
|
||
const targetBytes = PERFORMANCE_SURVEY_IMAGE_LIMITS.targetSliceSizeKB * 1024
|
||
let encoded: Buffer = Buffer.alloc(0)
|
||
for (const quality of [76, 64, 52, 42]) {
|
||
encoded = await sharp(raw.data, { raw: raw.info })
|
||
.webp({ quality, effort: 3, smartSubsample: true })
|
||
.toBuffer()
|
||
if (encoded.length <= targetBytes) break
|
||
}
|
||
|
||
return {
|
||
buffer: encoded,
|
||
sourceIndex: plan.sourceIndex,
|
||
sliceIndex,
|
||
sliceCount: plan.sliceCount,
|
||
sourceName: plan.sourceName,
|
||
width: plan.outputWidth,
|
||
height: outputHeight,
|
||
}
|
||
}
|
||
|
||
async function createPerformanceSurveyPreview(firstSlice: ProcessedPerformanceSurveyImage) {
|
||
const result = await sharp(firstSlice.buffer, {
|
||
limitInputPixels: PERFORMANCE_SURVEY_IMAGE_LIMITS.maxPixelsPerImage,
|
||
sequentialRead: true,
|
||
failOn: 'error',
|
||
})
|
||
.resize({
|
||
width: PERFORMANCE_SURVEY_IMAGE_LIMITS.previewWidth,
|
||
height: PERFORMANCE_SURVEY_IMAGE_LIMITS.previewHeight,
|
||
fit: 'cover',
|
||
position: 'top',
|
||
withoutEnlargement: true,
|
||
})
|
||
.webp({ quality: 68, effort: 3, smartSubsample: true })
|
||
.toBuffer({ resolveWithObject: true })
|
||
|
||
return {
|
||
buffer: result.data,
|
||
width: result.info.width,
|
||
height: result.info.height,
|
||
}
|
||
}
|
||
|
||
export async function processPerformanceSurveyImage(
|
||
input: PerformanceSurveyImageInput,
|
||
plan?: PerformanceSurveyImagePlan,
|
||
onSlice?: (slice: ProcessedPerformanceSurveyImage) => Promise<void> | void,
|
||
): Promise<ProcessedPerformanceSurveyImageSource> {
|
||
const inspected = plan || await inspectPerformanceSurveyImage(input)
|
||
const slices: ProcessedPerformanceSurveyImage[] = []
|
||
let firstSlice: ProcessedPerformanceSurveyImage | null = null
|
||
let processedCount = 0
|
||
for (let sliceIndex = 0; sliceIndex < inspected.sliceCount; sliceIndex += 1) {
|
||
const slice = await encodePerformanceSurveySlice(input, inspected, sliceIndex)
|
||
firstSlice ||= slice
|
||
processedCount += 1
|
||
if (onSlice) {
|
||
await onSlice(slice)
|
||
} else {
|
||
slices.push(slice)
|
||
}
|
||
}
|
||
if (processedCount !== inspected.sliceCount || !firstSlice) {
|
||
throw new PerformanceSurveyImageProcessingError(`图片「${input.sourceName}」切片数量异常`)
|
||
}
|
||
|
||
return {
|
||
plan: inspected,
|
||
preview: await createPerformanceSurveyPreview(firstSlice),
|
||
slices,
|
||
}
|
||
}
|
||
|
||
export async function processPerformanceSurveyImages(
|
||
inputs: PerformanceSurveyImageInput[],
|
||
): Promise<ProcessedPerformanceSurveyImage[]> {
|
||
if (inputs.length === 0) {
|
||
throw new PerformanceSurveyImageProcessingError('每个案例至少上传 1 张图片')
|
||
}
|
||
if (inputs.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount) {
|
||
throw new PerformanceSurveyImageProcessingError(
|
||
`单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张切片`,
|
||
)
|
||
}
|
||
const totalBytes = inputs.reduce((sum, input) => sum + input.buffer.length, 0)
|
||
if (totalBytes > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxLegacyBatchSourceBytes) {
|
||
throw new PerformanceSurveyImageProcessingError('单个案例上传总大小不能超过 60MB')
|
||
}
|
||
|
||
const plans: PerformanceSurveyImagePlan[] = []
|
||
for (let sourceIndex = 0; sourceIndex < inputs.length; sourceIndex += 1) {
|
||
plans.push(await inspectPerformanceSurveyImage(inputs[sourceIndex], sourceIndex))
|
||
}
|
||
assertPerformanceSurveyImageBatch(plans)
|
||
|
||
const output: ProcessedPerformanceSurveyImage[] = []
|
||
for (let sourceIndex = 0; sourceIndex < inputs.length; sourceIndex += 1) {
|
||
const processed = await processPerformanceSurveyImage(inputs[sourceIndex], plans[sourceIndex])
|
||
output.push(...processed.slices)
|
||
}
|
||
return output
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||
}
|
||
|
||
function normalizeStoredSlice(value: unknown, fallbackWidth: number): PerformanceSurveyStoredImageSlice | null {
|
||
if (typeof value === 'string') {
|
||
const url = value.trim()
|
||
return url ? { url, width: fallbackWidth, height: PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight } : null
|
||
}
|
||
if (!isRecord(value)) return null
|
||
const url = String(value.url || '').trim()
|
||
if (!url) return null
|
||
return {
|
||
url,
|
||
width: Math.max(1, Number(value.width) || fallbackWidth),
|
||
height: Math.max(1, Number(value.height) || PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight),
|
||
}
|
||
}
|
||
|
||
export function serializePerformanceSurveyImagePayload(value: unknown): PerformanceSurveyImagePayload {
|
||
if (!Array.isArray(value) || value.length === 0) return { imageUrls: [], imageGroups: [] }
|
||
|
||
const legacyUrls = value.filter((item): item is string => typeof item === 'string' && Boolean(item.trim()))
|
||
if (legacyUrls.length === value.length) {
|
||
const grouped = new Map<string, Array<{ url: string; order: number }>>()
|
||
legacyUrls.forEach((rawUrl, index) => {
|
||
const url = rawUrl.trim()
|
||
const match = url.match(/-(\d+)-(\d+)\.webp(?:\?|$)/i)
|
||
const key = match ? `source-${match[1]}` : `legacy-${index + 1}`
|
||
const items = grouped.get(key) || []
|
||
items.push({ url, order: match ? Number(match[2]) : 1 })
|
||
grouped.set(key, items)
|
||
})
|
||
const imageGroups = Array.from(grouped.entries()).map(([key, items], groupIndex) => {
|
||
const slices = items
|
||
.sort((left, right) => left.order - right.order)
|
||
.map((item) => ({
|
||
url: item.url,
|
||
width: 1_200,
|
||
height: PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight,
|
||
}))
|
||
return {
|
||
version: 1 as const,
|
||
sourceName: key.startsWith('source-') ? `历史图片 ${groupIndex + 1}` : '历史图片',
|
||
sourceWidth: 1_200,
|
||
sourceHeight: slices.length * PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight,
|
||
width: 1_200,
|
||
height: slices.length * PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight,
|
||
previewUrl: slices[0].url,
|
||
previewWidth: slices[0].width,
|
||
previewHeight: Math.min(slices[0].height, PERFORMANCE_SURVEY_IMAGE_LIMITS.previewHeight),
|
||
slices,
|
||
}
|
||
})
|
||
return {
|
||
imageUrls: imageGroups.flatMap((group) => group.slices.map((slice) => slice.url)),
|
||
imageGroups,
|
||
}
|
||
}
|
||
|
||
const imageGroups = value.flatMap((item): PerformanceSurveyStoredImageGroup[] => {
|
||
if (!isRecord(item)) return []
|
||
const width = Math.max(1, Number(item.width) || 1_200)
|
||
const slicesValue = Array.isArray(item.slices)
|
||
? item.slices
|
||
: Array.isArray(item.sliceUrls)
|
||
? item.sliceUrls
|
||
: []
|
||
const slices = slicesValue
|
||
.map((slice) => normalizeStoredSlice(slice, width))
|
||
.filter((slice): slice is PerformanceSurveyStoredImageSlice => Boolean(slice))
|
||
if (slices.length === 0) return []
|
||
const previewUrl = String(item.previewUrl || slices[0].url).trim()
|
||
return [{
|
||
version: 1,
|
||
sourceName: String(item.sourceName || '案例图片').trim() || '案例图片',
|
||
sourceWidth: Math.max(1, Number(item.sourceWidth) || width),
|
||
sourceHeight: Math.max(1, Number(item.sourceHeight) || Number(item.height) || slices.reduce((sum, slice) => sum + slice.height, 0)),
|
||
width,
|
||
height: Math.max(1, Number(item.height) || slices.reduce((sum, slice) => sum + slice.height, 0)),
|
||
previewUrl,
|
||
previewWidth: Math.max(1, Number(item.previewWidth) || Math.min(width, PERFORMANCE_SURVEY_IMAGE_LIMITS.previewWidth)),
|
||
previewHeight: Math.max(1, Number(item.previewHeight) || PERFORMANCE_SURVEY_IMAGE_LIMITS.previewHeight),
|
||
slices,
|
||
}]
|
||
})
|
||
|
||
return {
|
||
imageUrls: imageGroups.flatMap((group) => group.slices.map((slice) => slice.url)),
|
||
imageGroups,
|
||
}
|
||
}
|