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

339 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// <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' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail'
}
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
let previewWidth = PREVIEW_WIDTH
let previewHeight = PREVIEW_HEIGHT
let previewQuality = PREVIEW_QUALITY
let previewFit: 'cover' | 'inside' = 'cover'
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())
let headerDimensions = readPerformanceSurveySourceImageDimensions(header)
if (!headerDimensions && file.size > header.byteLength) {
// JPEG 的 EXIF/ICC 段可能超过 256KB。再读取有限的扩展头部,优先在解码前
// 拦截超大像素图片;只有仍无法识别时才交给浏览器实际解码。
const extendedHeader = new Uint8Array(
await file.slice(0, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.extendedHeaderReadBytes).arrayBuffer(),
)
headerDimensions = readPerformanceSurveySourceImageDimensions(extendedHeader)
}
// 常见图片会在文件头直接提供尺寸,先做保护性校验,避免超大图片进入浏览器解码。
// JPEG 的 EXIF/ICC 段可能超过 256KB;头部暂时读不到 SOF 时交给浏览器实际解码,
// 再用解码后的尺寸做同一套限制,不能把合法图片误判成“不支持”。
if (headerDimensions) {
assertPerformanceSurveyBrowserSourceImageDimensions(headerDimensions, file.name)
}
let bitmap: ImageBitmap
try {
bitmap = await createImageBitmap(file)
} catch {
throw new Error(`图片「${file.name}」无法解码,请确认文件没有损坏并使用 PNG、JPG 或 WebP 格式`)
}
try {
assertPerformanceSurveyBrowserSourceImageDimensions(
{ width: bitmap.width, height: bitmap.height },
file.name,
)
return bitmap
} catch (error) {
bitmap.close()
throw error
}
}
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(previewWidth, sliceWidth)
const targetHeight = Math.min(previewHeight, sliceHeight)
if (previewFit === 'inside') {
const scale = Math.min(targetWidth / bitmap.width, targetHeight / bitmap.height, 1)
const width = Math.max(1, Math.round(bitmap.width * scale))
const height = Math.max(1, Math.round(bitmap.height * scale))
const canvas = new OffscreenCanvas(width, height)
const context = getCanvasContext(canvas)
context.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height, 0, 0, width, height)
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: previewQuality })
return { blob, width, height }
}
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: previewQuality })
return { blob, width: targetWidth, height: targetHeight }
} finally {
bitmap.close()
}
}
async function processSingleOriginal(file: File, includePreview = true): 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,
})
}
if (!includePreview) return slices
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 renderPreviewFromBitmap(bitmap: ImageBitmap) {
const scale = Math.min(previewWidth / bitmap.width, previewHeight / bitmap.height, 1)
const width = Math.max(1, Math.round(bitmap.width * scale))
const height = Math.max(1, Math.round(bitmap.height * scale))
const canvas = new OffscreenCanvas(width, height)
const context = getCanvasContext(canvas)
context.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height, 0, 0, width, height)
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: previewQuality })
return { blob, width, height }
}
async function processSinglePreview(file: File): Promise<WorkerProcessedImage[]> {
const bitmap = await decodeImage(file)
try {
if (!bitmap.width || !bitmap.height) throw new Error(`图片「${file.name}」尺寸无效`)
const preview = await renderPreviewFromBitmap(bitmap)
return [{
...preview,
fileName: `${removeFileExtension(file.name)}-preview.webp`,
mimeType: 'image/webp',
kind: 'preview',
sourceName: file.name,
sourceIndex: 0,
sliceIndex: 0,
sliceCount: 1,
}]
} 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
const isTaskDetail = profile === 'task_detail' || profile === 'task_detail_preview' || profile === 'task_detail_detail'
previewWidth = isTaskDetail ? 800 : PREVIEW_WIDTH
previewHeight = isTaskDetail ? 1_200 : PREVIEW_HEIGHT
previewQuality = isTaskDetail ? 0.74 : PREVIEW_QUALITY
previewFit = isTaskDetail ? 'inside' : 'cover'
if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') {
throw new Error('图片需要在桌面版 Chrome 或 Edge 中处理,请更换浏览器后重新上传')
}
const images = files.length === 1
? profile === 'task_detail_preview'
? await processSinglePreview(files[0])
: await processSingleOriginal(files[0], profile !== 'task_detail_detail')
: await processOrderedSlices(files)
workerScope.postMessage({ id, ok: true, images })
} catch (error) {
workerScope.postMessage({
id,
ok: false,
error: error instanceof Error ? error.message : '浏览器图片处理失败',
})
}
}