264 lines
8.8 KiB
TypeScript
264 lines
8.8 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'
|
|
}
|
|
|
|
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
|
|
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())
|
|
assertPerformanceSurveyBrowserSourceImageDimensions(
|
|
readPerformanceSurveySourceImageDimensions(header),
|
|
file.name,
|
|
)
|
|
try {
|
|
return await createImageBitmap(file)
|
|
} catch {
|
|
throw new Error(`图片「${file.name}」无法解码,请确认文件没有损坏并使用 PNG、JPG 或 WebP 格式`)
|
|
}
|
|
}
|
|
|
|
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(PREVIEW_WIDTH, sliceWidth)
|
|
const targetHeight = Math.min(PREVIEW_HEIGHT, sliceHeight)
|
|
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: PREVIEW_QUALITY })
|
|
return { blob, width: targetWidth, height: targetHeight }
|
|
} finally {
|
|
bitmap.close()
|
|
}
|
|
}
|
|
|
|
async function processSingleOriginal(file: File): 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,
|
|
})
|
|
}
|
|
|
|
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 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
|
|
if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') {
|
|
throw new Error('图片需要在桌面版 Chrome 或 Edge 中处理,请更换浏览器后重新上传')
|
|
}
|
|
const images = files.length === 1
|
|
? await processSingleOriginal(files[0])
|
|
: await processOrderedSlices(files)
|
|
workerScope.postMessage({ id, ok: true, images })
|
|
} catch (error) {
|
|
workerScope.postMessage({
|
|
id,
|
|
ok: false,
|
|
error: error instanceof Error ? error.message : '浏览器图片处理失败',
|
|
})
|
|
}
|
|
}
|