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