gongxiangp2/lib/performance-survey-image-browser.ts
2026-08-03 09:53:20 +08:00

195 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
}))
}