195 lines
6.4 KiB
TypeScript
195 lines
6.4 KiB
TypeScript
import { sortPerformanceSurveyImagesByName } from '@/lib/performance-survey-image-order'
|
||
import {
|
||
PerformanceSurveyBrowserImageError,
|
||
type PerformanceSurveyBrowserImageErrorCode,
|
||
} from '@/lib/performance-survey-image-error'
|
||
|
||
export type PerformanceSurveyProcessedImageKind = 'slice' | 'preview'
|
||
|
||
export type PerformanceSurveyBrowserProcessedImage = {
|
||
file: File
|
||
mimeType: 'image/webp'
|
||
width: number
|
||
height: number
|
||
kind: PerformanceSurveyProcessedImageKind
|
||
sourceName: string
|
||
sourceIndex: number
|
||
sliceIndex: number
|
||
sliceCount: number
|
||
}
|
||
|
||
type WorkerProcessedImage = Omit<PerformanceSurveyBrowserProcessedImage, 'file'> & {
|
||
blob: Blob
|
||
fileName: string
|
||
}
|
||
|
||
type WorkerSuccessMessage = {
|
||
id: string
|
||
ok: true
|
||
images: WorkerProcessedImage[]
|
||
}
|
||
|
||
type WorkerErrorMessage = {
|
||
id: string
|
||
ok: false
|
||
code?: PerformanceSurveyBrowserImageErrorCode
|
||
error: string
|
||
}
|
||
|
||
const DESKTOP_BROWSER_REQUIRED_MESSAGE = '图片需要在桌面版 Chrome 或 Edge 中处理,请更换浏览器后重新上传'
|
||
|
||
// 任务详情单图允许到 100MB / 60MP,低性能办公电脑处理可能需要数分钟。
|
||
// 10 分钟与项目现有大文件处理上限一致,既避免误杀慢设备,又能终止永不回包的 Worker。
|
||
export const PERFORMANCE_SURVEY_IMAGE_WORKER_TIMEOUT_MS = 10 * 60 * 1000
|
||
|
||
export type PerformanceSurveyBrowserImageOptions = {
|
||
profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail' | 'task_detail_thumbnail'
|
||
signal?: AbortSignal
|
||
timeoutMs?: number
|
||
}
|
||
|
||
function createBrowserImageAbortError() {
|
||
return new DOMException('图片处理已取消', 'AbortError')
|
||
}
|
||
|
||
function assertPerformanceSurveyBrowserImageSupport() {
|
||
if (
|
||
typeof window === 'undefined'
|
||
|| typeof Worker === 'undefined'
|
||
|| typeof OffscreenCanvas === 'undefined'
|
||
|| typeof createImageBitmap === 'undefined'
|
||
) {
|
||
throw new PerformanceSurveyBrowserImageError(
|
||
'BROWSER_CAPABILITY_UNAVAILABLE',
|
||
DESKTOP_BROWSER_REQUIRED_MESSAGE,
|
||
)
|
||
}
|
||
}
|
||
|
||
function runImageWorker(
|
||
files: File[],
|
||
profile: NonNullable<PerformanceSurveyBrowserImageOptions['profile']>,
|
||
options: Pick<PerformanceSurveyBrowserImageOptions, 'signal' | 'timeoutMs'> = {},
|
||
): Promise<WorkerProcessedImage[]> {
|
||
if (options.signal?.aborted) {
|
||
return Promise.reject(createBrowserImageAbortError())
|
||
}
|
||
assertPerformanceSurveyBrowserImageSupport()
|
||
|
||
return new Promise((resolve, reject) => {
|
||
let worker: Worker
|
||
try {
|
||
worker = new Worker(new URL('./performance-survey-image.worker.ts', import.meta.url), { type: 'module' })
|
||
} catch {
|
||
reject(new PerformanceSurveyBrowserImageError(
|
||
'WORKER_UNAVAILABLE',
|
||
'浏览器图片处理线程启动失败,请使用桌面版 Chrome 或 Edge 重试',
|
||
))
|
||
return
|
||
}
|
||
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||
let settled = false
|
||
let watchdogTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
function handleAbort() {
|
||
finish(() => reject(createBrowserImageAbortError()))
|
||
}
|
||
|
||
function finish(callback: () => void) {
|
||
if (settled) return
|
||
settled = true
|
||
if (watchdogTimer !== null) {
|
||
clearTimeout(watchdogTimer)
|
||
watchdogTimer = null
|
||
}
|
||
options.signal?.removeEventListener('abort', handleAbort)
|
||
worker.onmessage = null
|
||
worker.onerror = null
|
||
worker.onmessageerror = null
|
||
worker.terminate()
|
||
callback()
|
||
}
|
||
|
||
worker.onmessage = (event: MessageEvent<WorkerSuccessMessage | WorkerErrorMessage>) => {
|
||
const data = event.data
|
||
if (data?.id !== requestId) return
|
||
if (data.ok === true) {
|
||
finish(() => resolve(data.images))
|
||
} else {
|
||
finish(() => reject(new PerformanceSurveyBrowserImageError(
|
||
data.code || 'PROCESSING_FAILED',
|
||
data.error || '浏览器图片处理失败',
|
||
)))
|
||
}
|
||
}
|
||
worker.onerror = () => {
|
||
finish(() => reject(new PerformanceSurveyBrowserImageError(
|
||
'WORKER_UNAVAILABLE',
|
||
'浏览器图片处理线程启动失败,请使用桌面版 Chrome 或 Edge 重试',
|
||
)))
|
||
}
|
||
worker.onmessageerror = () => {
|
||
finish(() => reject(new PerformanceSurveyBrowserImageError(
|
||
'WORKER_UNAVAILABLE',
|
||
'浏览器图片处理线程无法返回结果,请重试',
|
||
)))
|
||
}
|
||
|
||
options.signal?.addEventListener('abort', handleAbort, { once: true })
|
||
const configuredTimeout = Number(options.timeoutMs)
|
||
const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0
|
||
? configuredTimeout
|
||
: PERFORMANCE_SURVEY_IMAGE_WORKER_TIMEOUT_MS
|
||
watchdogTimer = setTimeout(() => {
|
||
const message = profile.startsWith('task_detail')
|
||
? '浏览器图片处理超时,已停止本地处理,将改由服务器安全处理'
|
||
: '浏览器图片处理超时,请减少单次图片数量后重试'
|
||
finish(() => reject(new PerformanceSurveyBrowserImageError('WORKER_TIMEOUT', message)))
|
||
}, timeoutMs)
|
||
|
||
try {
|
||
worker.postMessage({ id: requestId, files, profile })
|
||
} catch {
|
||
finish(() => reject(new PerformanceSurveyBrowserImageError(
|
||
'WORKER_UNAVAILABLE',
|
||
'浏览器图片处理线程无法接收文件,请使用桌面版 Chrome 或 Edge 重试',
|
||
)))
|
||
}
|
||
})
|
||
}
|
||
|
||
export async function preparePerformanceSurveyImagesInBrowser(
|
||
files: File[],
|
||
onProgress?: (label: string) => void,
|
||
options: PerformanceSurveyBrowserImageOptions = {},
|
||
): Promise<PerformanceSurveyBrowserProcessedImage[]> {
|
||
if (files.length === 0) throw new Error('请至少选择 1 张图片')
|
||
|
||
const orderedFiles = sortPerformanceSurveyImagesByName(files, (file) => file.name)
|
||
onProgress?.(
|
||
orderedFiles.length === 1
|
||
? '正在浏览器后台生成预览和 WebP 切片'
|
||
: `正在浏览器后台转换 ${orderedFiles.length} 张有序切片为 WebP`,
|
||
)
|
||
const processed = await runImageWorker(
|
||
orderedFiles,
|
||
options.profile || 'performance_survey',
|
||
{ signal: options.signal, timeoutMs: options.timeoutMs },
|
||
)
|
||
|
||
return processed.map((item) => ({
|
||
file: new File([item.blob], item.fileName, {
|
||
type: item.mimeType,
|
||
lastModified: Date.now(),
|
||
}),
|
||
mimeType: item.mimeType,
|
||
width: item.width,
|
||
height: item.height,
|
||
kind: item.kind,
|
||
sourceName: item.sourceName,
|
||
sourceIndex: item.sourceIndex,
|
||
sliceIndex: item.sliceIndex,
|
||
sliceCount: item.sliceCount,
|
||
}))
|
||
}
|