gongxiangp2/lib/performance-survey-image.worker.ts

264 lines
8.8 KiB
TypeScript

/// <reference lib="webworker" />
import {
assertPerformanceSurveyBrowserSourceImageDimensions,
PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS,
readPerformanceSurveySourceImageDimensions,
} from './performance-survey-image-source-guard'
export {}
type WorkerRequest = {
id: string
files: File[]
profile?: 'performance_survey' | 'work_review'
}
type WorkerProcessedImage = {
blob: Blob
fileName: string
mimeType: 'image/webp'
width: number
height: number
kind: 'slice' | 'preview'
sourceName: string
sourceIndex: number
sliceIndex: number
sliceCount: number
}
const SLICE_HEIGHT = 1_500
const PREVIEW_WIDTH = 720
const PREVIEW_HEIGHT = 560
const MAX_SLICES_PER_SOURCE = 60
const MAX_ORDERED_SLICE_COUNT = 60
const PREVIEW_QUALITY = 0.68
let outputWidthLimit = 1_600
let sliceQuality = 0.76
const workerScope = self as unknown as DedicatedWorkerGlobalScope
function removeFileExtension(fileName: string) {
const extensionIndex = fileName.lastIndexOf('.')
return extensionIndex > 0 ? fileName.slice(0, extensionIndex) : fileName
}
function getCanvasContext(canvas: OffscreenCanvas) {
const context = canvas.getContext('2d', { alpha: false })
if (!context) throw new Error('当前浏览器无法创建图片处理画布,请使用桌面版 Chrome 或 Edge')
return context
}
async function decodeImage(file: File) {
const header = new Uint8Array(await file.slice(0, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.headerReadBytes).arrayBuffer())
assertPerformanceSurveyBrowserSourceImageDimensions(
readPerformanceSurveySourceImageDimensions(header),
file.name,
)
try {
return await createImageBitmap(file)
} catch {
throw new Error(`图片「${file.name}」无法解码,请确认文件没有损坏并使用 PNG、JPG 或 WebP 格式`)
}
}
async function renderSlice(
bitmap: ImageBitmap,
outputWidth: number,
outputHeight: number,
scale: number,
sliceIndex: number,
sliceCount: number,
) {
const outputTop = sliceIndex * SLICE_HEIGHT
const sliceHeight = Math.min(SLICE_HEIGHT, outputHeight - outputTop)
const sourceTop = Math.floor(outputTop / scale)
const sourceBottom = sliceIndex === sliceCount - 1
? bitmap.height
: Math.min(bitmap.height, Math.ceil((outputTop + sliceHeight) / scale))
const sourceHeight = Math.max(1, sourceBottom - sourceTop)
const canvas = new OffscreenCanvas(outputWidth, sliceHeight)
const context = getCanvasContext(canvas)
context.drawImage(
bitmap,
0,
sourceTop,
bitmap.width,
sourceHeight,
0,
0,
outputWidth,
sliceHeight,
)
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: sliceQuality })
return { blob, width: outputWidth, height: sliceHeight }
}
async function renderPreview(firstSlice: Blob, sliceWidth: number, sliceHeight: number) {
const bitmap = await createImageBitmap(firstSlice)
try {
const targetWidth = Math.min(PREVIEW_WIDTH, sliceWidth)
const targetHeight = Math.min(PREVIEW_HEIGHT, sliceHeight)
const coverScale = Math.max(targetWidth / bitmap.width, targetHeight / bitmap.height)
const sourceWidth = Math.min(bitmap.width, targetWidth / coverScale)
const sourceHeight = Math.min(bitmap.height, targetHeight / coverScale)
const sourceLeft = Math.max(0, (bitmap.width - sourceWidth) / 2)
const canvas = new OffscreenCanvas(targetWidth, targetHeight)
const context = getCanvasContext(canvas)
context.drawImage(
bitmap,
sourceLeft,
0,
sourceWidth,
sourceHeight,
0,
0,
targetWidth,
targetHeight,
)
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: PREVIEW_QUALITY })
return { blob, width: targetWidth, height: targetHeight }
} finally {
bitmap.close()
}
}
async function processSingleOriginal(file: File): Promise<WorkerProcessedImage[]> {
const bitmap = await decodeImage(file)
try {
if (!bitmap.width || !bitmap.height) throw new Error(`图片「${file.name}」尺寸无效`)
const scale = Math.min(1, outputWidthLimit / bitmap.width)
const outputWidth = Math.max(1, Math.round(bitmap.width * scale))
const outputHeight = Math.max(1, Math.round(bitmap.height * scale))
const sliceCount = Math.max(1, Math.ceil(outputHeight / SLICE_HEIGHT))
if (sliceCount > MAX_SLICES_PER_SOURCE) {
throw new Error(`图片「${file.name}」处理后需要 ${sliceCount} 个切片,单张长图最多支持 ${MAX_SLICES_PER_SOURCE} 个切片`)
}
const baseName = removeFileExtension(file.name)
const slices: WorkerProcessedImage[] = []
for (let sliceIndex = 0; sliceIndex < sliceCount; sliceIndex += 1) {
const rendered = await renderSlice(bitmap, outputWidth, outputHeight, scale, sliceIndex, sliceCount)
slices.push({
...rendered,
fileName: `${baseName}-${String(sliceIndex + 1).padStart(3, '0')}.webp`,
mimeType: 'image/webp',
kind: 'slice',
sourceName: file.name,
sourceIndex: 0,
sliceIndex,
sliceCount,
})
}
const preview = await renderPreview(slices[0].blob, slices[0].width, slices[0].height)
return [{
...preview,
fileName: `${baseName}-preview.webp`,
mimeType: 'image/webp',
kind: 'preview',
sourceName: file.name,
sourceIndex: 0,
sliceIndex: 0,
sliceCount,
}, ...slices]
} catch (error) {
throw new Error(
error instanceof Error && error.message
? error.message
: `图片「${file.name}」无法在浏览器中处理`,
)
} finally {
bitmap.close()
}
}
async function processOrderedSlices(files: File[]): Promise<WorkerProcessedImage[]> {
if (files.length > MAX_ORDERED_SLICE_COUNT) {
throw new Error(`单个案例最多上传 ${MAX_ORDERED_SLICE_COUNT} 张有序切片`)
}
const plans: Array<{
outputWidth: number
outputHeight: number
scale: number
sliceCount: number
}> = []
let totalSliceCount = 0
for (const file of files) {
const bitmap = await decodeImage(file)
try {
if (!bitmap.width || !bitmap.height) throw new Error(`图片「${file.name}」尺寸无效`)
const scale = Math.min(1, outputWidthLimit / bitmap.width)
const outputWidth = Math.max(1, Math.round(bitmap.width * scale))
const outputHeight = Math.max(1, Math.round(bitmap.height * scale))
const sliceCount = Math.max(1, Math.ceil(outputHeight / SLICE_HEIGHT))
if (sliceCount > MAX_SLICES_PER_SOURCE) {
throw new Error(`图片「${file.name}」处理后需要 ${sliceCount} 个切片,单张源图最多支持 ${MAX_SLICES_PER_SOURCE} 个切片`)
}
totalSliceCount += sliceCount
plans.push({ outputWidth, outputHeight, scale, sliceCount })
} finally {
bitmap.close()
}
}
if (totalSliceCount > MAX_ORDERED_SLICE_COUNT) {
throw new Error(`这些图片处理后共需 ${totalSliceCount} 个切片,单个案例最多支持 ${MAX_ORDERED_SLICE_COUNT} 个切片`)
}
const processed: WorkerProcessedImage[] = []
for (let sourceIndex = 0; sourceIndex < files.length; sourceIndex += 1) {
const file = files[sourceIndex]
const plan = plans[sourceIndex]
const bitmap = await decodeImage(file)
try {
for (let sliceIndex = 0; sliceIndex < plan.sliceCount; sliceIndex += 1) {
const rendered = await renderSlice(
bitmap,
plan.outputWidth,
plan.outputHeight,
plan.scale,
sliceIndex,
plan.sliceCount,
)
processed.push({
...rendered,
fileName: plan.sliceCount === 1
? `${removeFileExtension(file.name)}.webp`
: `${removeFileExtension(file.name)}-${String(sliceIndex + 1).padStart(3, '0')}.webp`,
mimeType: 'image/webp',
kind: 'slice',
sourceName: file.name,
sourceIndex,
sliceIndex,
sliceCount: plan.sliceCount,
})
}
} finally {
bitmap.close()
}
}
return processed
}
workerScope.onmessage = async (event: MessageEvent<WorkerRequest>) => {
const { id, files, profile } = event.data || {}
try {
if (!id || !Array.isArray(files) || files.length === 0) throw new Error('没有可处理的图片')
outputWidthLimit = profile === 'work_review' ? 3_200 : 1_600
sliceQuality = profile === 'work_review' ? 0.9 : 0.76
if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') {
throw new Error('图片需要在桌面版 Chrome 或 Edge 中处理,请更换浏览器后重新上传')
}
const images = files.length === 1
? await processSingleOriginal(files[0])
: await processOrderedSlices(files)
workerScope.postMessage({ id, ok: true, images })
} catch (error) {
workerScope.postMessage({
id,
ok: false,
error: error instanceof Error ? error.message : '浏览器图片处理失败',
})
}
}