405 lines
15 KiB
TypeScript
405 lines
15 KiB
TypeScript
/// <reference lib="webworker" />
|
||
|
||
import {
|
||
assertPerformanceSurveyBrowserSourceImageDimensions,
|
||
PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS,
|
||
readPerformanceSurveySourceImageDimensions,
|
||
TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS,
|
||
} from './performance-survey-image-source-guard'
|
||
import { PerformanceSurveyBrowserImageError } from './performance-survey-image-error'
|
||
|
||
export {}
|
||
|
||
type WorkerRequest = {
|
||
id: string
|
||
files: File[]
|
||
profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail' | 'task_detail_thumbnail'
|
||
}
|
||
|
||
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 TASK_DETAIL_THUMBNAIL_SIZE = 320
|
||
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'
|
||
let sourceMaxPixels: number = PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels
|
||
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 PerformanceSurveyBrowserImageError(
|
||
'CANVAS_UNAVAILABLE',
|
||
'当前浏览器无法创建图片处理画布,请使用桌面版 Chrome 或 Edge',
|
||
)
|
||
}
|
||
return context
|
||
}
|
||
|
||
async function exportCanvasAsWebp(canvas: OffscreenCanvas, quality: number) {
|
||
try {
|
||
return await canvas.convertToBlob({ type: 'image/webp', quality })
|
||
} catch {
|
||
throw new PerformanceSurveyBrowserImageError(
|
||
'CANVAS_EXPORT_UNAVAILABLE',
|
||
'当前浏览器无法导出 WebP 预览,请使用桌面版 Chrome 或 Edge',
|
||
)
|
||
}
|
||
}
|
||
|
||
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, { maxPixels: sourceMaxPixels })
|
||
}
|
||
let bitmap: ImageBitmap
|
||
try {
|
||
bitmap = await createImageBitmap(file)
|
||
} catch {
|
||
if (headerDimensions) {
|
||
throw new PerformanceSurveyBrowserImageError(
|
||
'BROWSER_DECODE_UNAVAILABLE',
|
||
`当前浏览器无法解码图片「${file.name}」,将改由服务器安全处理`,
|
||
)
|
||
}
|
||
throw new PerformanceSurveyBrowserImageError(
|
||
'SOURCE_DECODE_FAILED',
|
||
`图片「${file.name}」无法解码,请确认文件没有损坏并使用 PNG、JPG、GIF 或 WebP 格式`,
|
||
)
|
||
}
|
||
try {
|
||
assertPerformanceSurveyBrowserSourceImageDimensions(
|
||
{ width: bitmap.width, height: bitmap.height },
|
||
file.name,
|
||
{ maxPixels: sourceMaxPixels },
|
||
)
|
||
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 exportCanvasAsWebp(canvas, 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 exportCanvasAsWebp(canvas, 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 exportCanvasAsWebp(canvas, 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 PerformanceSurveyBrowserImageError('SOURCE_DIMENSIONS_INVALID', `图片「${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 PerformanceSurveyBrowserImageError(
|
||
'SOURCE_SLICE_LIMIT',
|
||
`图片「${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) {
|
||
if (error instanceof PerformanceSurveyBrowserImageError) throw error
|
||
throw new PerformanceSurveyBrowserImageError(
|
||
'PROCESSING_FAILED',
|
||
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 exportCanvasAsWebp(canvas, previewQuality)
|
||
return { blob, width, height }
|
||
}
|
||
|
||
async function processSinglePreview(
|
||
file: File,
|
||
fileNameSuffix: 'preview' | 'thumbnail' = 'preview',
|
||
): Promise<WorkerProcessedImage[]> {
|
||
const bitmap = await decodeImage(file)
|
||
try {
|
||
if (!bitmap.width || !bitmap.height) {
|
||
throw new PerformanceSurveyBrowserImageError('SOURCE_DIMENSIONS_INVALID', `图片「${file.name}」尺寸无效`)
|
||
}
|
||
const preview = await renderPreviewFromBitmap(bitmap)
|
||
return [{
|
||
...preview,
|
||
fileName: `${removeFileExtension(file.name)}-${fileNameSuffix}.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 PerformanceSurveyBrowserImageError(
|
||
'SOURCE_COUNT_LIMIT',
|
||
`单个案例最多上传 ${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 PerformanceSurveyBrowserImageError('SOURCE_DIMENSIONS_INVALID', `图片「${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 PerformanceSurveyBrowserImageError(
|
||
'SOURCE_SLICE_LIMIT',
|
||
`图片「${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 PerformanceSurveyBrowserImageError(
|
||
'SOURCE_SLICE_LIMIT',
|
||
`这些图片处理后共需 ${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 PerformanceSurveyBrowserImageError('NO_SOURCE_IMAGES', '没有可处理的图片')
|
||
}
|
||
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'
|
||
|| profile === 'task_detail_thumbnail'
|
||
const isTaskDetailThumbnail = profile === 'task_detail_thumbnail'
|
||
sourceMaxPixels = isTaskDetail
|
||
? TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS
|
||
: PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels
|
||
previewWidth = isTaskDetailThumbnail ? TASK_DETAIL_THUMBNAIL_SIZE : isTaskDetail ? 800 : PREVIEW_WIDTH
|
||
previewHeight = isTaskDetailThumbnail ? TASK_DETAIL_THUMBNAIL_SIZE : isTaskDetail ? 1_200 : PREVIEW_HEIGHT
|
||
previewQuality = isTaskDetailThumbnail ? 0.68 : isTaskDetail ? 0.74 : PREVIEW_QUALITY
|
||
previewFit = isTaskDetail ? 'inside' : 'cover'
|
||
if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') {
|
||
throw new PerformanceSurveyBrowserImageError(
|
||
'BROWSER_CAPABILITY_UNAVAILABLE',
|
||
'图片需要在桌面版 Chrome 或 Edge 中处理,请更换浏览器后重新上传',
|
||
)
|
||
}
|
||
const images = files.length === 1
|
||
? profile === 'task_detail_preview' || profile === 'task_detail_thumbnail'
|
||
? await processSinglePreview(files[0], profile === 'task_detail_thumbnail' ? 'thumbnail' : 'preview')
|
||
: await processSingleOriginal(files[0], profile !== 'task_detail_detail')
|
||
: await processOrderedSlices(files)
|
||
workerScope.postMessage({ id, ok: true, images })
|
||
} catch (error) {
|
||
workerScope.postMessage({
|
||
id,
|
||
ok: false,
|
||
code: error instanceof PerformanceSurveyBrowserImageError ? error.code : 'PROCESSING_FAILED',
|
||
error: error instanceof Error ? error.message : '浏览器图片处理失败',
|
||
})
|
||
}
|
||
}
|