339 lines
12 KiB
TypeScript
339 lines
12 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' | '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 : '浏览器图片处理失败',
|
||
})
|
||
}
|
||
}
|