///
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 {
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 {
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 {
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) => {
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 : '浏览器图片处理失败',
})
}
}