feat(image-conversion): add queue management for image conversion tasks and update loading states

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-10-16 22:07:12 +08:00
parent 6c9406585d
commit 8b07b1384e
12 changed files with 313 additions and 136 deletions

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
interface LoadingState {
isVisible: boolean
isConverting: boolean
isQueueWaiting: boolean
isHeicFormat: boolean
loadingProgress: number
loadedBytes: number
@@ -34,6 +35,7 @@ const initialLoadingState: LoadingState = {
loadedBytes: 0,
totalBytes: 0,
conversionMessage: undefined,
isQueueWaiting: false,
isWebGLLoading: false,
webglMessage: undefined,
@@ -99,7 +101,9 @@ export const LoadingIndicator = ({
// 视频转换状态
<>
<p className="text-xs font-medium text-white tabular-nums">
{loadingState.conversionMessage || t('loading.converting')}
{loadingState.isQueueWaiting
? loadingState.conversionMessage || t('loading.queue.waiting')
: loadingState.conversionMessage || t('loading.converting')}
</p>
</>
) : loadingState.isWebGLLoading ? (

View File

@@ -2,7 +2,12 @@
* 图像转换策略模式实现
* 支持多种浏览器原生不支持的图片格式转换
*/
import { i18nAtom } from '~/i18n'
import { jotaiStore } from '~/lib/jotai'
import type { LoadingCallbacks } from '../image-loader-manager'
import type { PipelineOptions } from './pipeline'
import { ImageConversionPipeline } from './pipeline'
import { HeicConverterStrategy } from './strategies/heic'
import { TiffConverterStrategy } from './strategies/tiff'
import type { ConversionResult, ImageConverterStrategy } from './type'
@@ -10,8 +15,16 @@ import type { ConversionResult, ImageConverterStrategy } from './type'
// 图像转换策略管理器
export class ImageConverterManager {
private strategies = new Map<string, ImageConverterStrategy>()
private readonly conversionPipeline: ImageConversionPipeline
private readonly pendingConversions = new Map<
string,
Promise<ConversionResult>
>()
constructor() {
constructor(options: PipelineOptions = {}) {
this.conversionPipeline = new ImageConversionPipeline({
maxConcurrent: options.maxConcurrent ?? 2,
})
// 注册默认策略
this.registerStrategy(new HeicConverterStrategy())
this.registerStrategy(new TiffConverterStrategy())
@@ -120,7 +133,44 @@ export class ImageConverterManager {
}
console.info(`Converting image using ${strategy.getName()} strategy`)
return await strategy.convert(blob, originalUrl, callbacks)
const taskKey = this.getConversionTaskKey(strategy, originalUrl)
const onLoadingStateUpdate = callbacks?.onLoadingStateUpdate
const pipelineActive = this.conversionPipeline.getActiveCount()
const maxConcurrent = this.conversionPipeline.getMaxConcurrent()
const isPipelineSaturated = pipelineActive >= maxConcurrent
const existingTask = this.pendingConversions.get(taskKey)
if (existingTask) {
console.info(
`Joining pending conversion task for ${strategy.getName()} (${originalUrl})`,
)
return await existingTask
}
if (onLoadingStateUpdate && isPipelineSaturated) {
const i18n = jotaiStore.get(i18nAtom)
onLoadingStateUpdate({
isConverting: true,
isQueueWaiting: true,
conversionMessage: i18n.t('loading.queue.waiting'),
})
}
const conversionPromise = this.conversionPipeline.enqueue(async () => {
try {
onLoadingStateUpdate?.({
isQueueWaiting: false,
conversionMessage: undefined,
})
return await strategy.convert(blob, originalUrl, callbacks)
} finally {
this.pendingConversions.delete(taskKey)
}
})
this.pendingConversions.set(taskKey, conversionPromise)
return await conversionPromise
}
/**
@@ -129,6 +179,30 @@ export class ImageConverterManager {
getSupportedFormats(): string[] {
return Array.from(this.strategies.keys())
}
getPipelineStats(): {
active: number
pending: number
} {
return {
active: this.conversionPipeline.getActiveCount(),
pending: this.conversionPipeline.getPendingCount(),
}
}
/**
* 调整管道的最大并发转换数量
*/
setMaxConcurrentConversions(maxConcurrent: number): void {
this.conversionPipeline.setMaxConcurrent(maxConcurrent)
}
private getConversionTaskKey(
strategy: ImageConverterStrategy,
originalUrl: string,
): string {
return `${strategy.getName()}::${originalUrl}`
}
}
// 导出单例实例

View File

@@ -0,0 +1,90 @@
export interface PipelineOptions {
maxConcurrent?: number
}
type TaskExecutor<T> = () => Promise<T>
type QueueTask<T> = {
execute: TaskExecutor<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
}
/**
* Lightweight promise queue to throttle image conversion workloads
*/
export class ImageConversionPipeline {
private maxConcurrent: number
private readonly queue: Array<QueueTask<any>> = []
private activeCount = 0
constructor(options: PipelineOptions = {}) {
const { maxConcurrent = 2 } = options
this.maxConcurrent = Math.max(1, maxConcurrent)
}
enqueue<T>(task: TaskExecutor<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const queueTask: QueueTask<T> = {
execute: task,
resolve,
reject,
}
this.queue.push(queueTask)
this.drainQueue()
})
}
getActiveCount(): number {
return this.activeCount
}
getPendingCount(): number {
return this.queue.length
}
getMaxConcurrent(): number {
return this.maxConcurrent
}
setMaxConcurrent(maxConcurrent: number): void {
if (!Number.isFinite(maxConcurrent)) {
throw new TypeError('maxConcurrent must be a finite number')
}
const normalizedValue = Math.max(1, Math.floor(maxConcurrent))
if (normalizedValue === this.maxConcurrent) {
return
}
this.maxConcurrent = normalizedValue
this.drainQueue()
}
private drainQueue(): void {
if (this.activeCount >= this.maxConcurrent) {
return
}
const nextTask = this.queue.shift()
if (!nextTask) {
return
}
this.activeCount += 1
// Run task asynchronously without blocking
;(async () => {
try {
const result = await nextTask.execute()
nextTask.resolve(result)
} catch (error) {
nextTask.reject(error)
} finally {
this.activeCount = Math.max(0, this.activeCount - 1)
this.drainQueue()
}
})()
}
}

View File

@@ -42,6 +42,7 @@ export class HeicConverterStrategy implements ImageConverterStrategy {
// 更新转换状态
onLoadingStateUpdate?.({
isConverting: true,
isQueueWaiting: false,
conversionMessage: i18n.t('loading.heic.converting'),
isHeicFormat: true,
loadingProgress: 100,

View File

@@ -28,6 +28,7 @@ export class TiffConverterStrategy implements ImageConverterStrategy {
// 更新转换状态
onLoadingStateUpdate?.({
isConverting: true,
isQueueWaiting: false,
conversionMessage: 'Converting TIFF image...',
})
@@ -48,7 +49,7 @@ export class TiffConverterStrategy implements ImageConverterStrategy {
// 浏览器支持检测
private isBrowserSupportTiff(): boolean {
// safari 支持tiff
// safari 支持 tiff
if (isSafari) {
return true
}
@@ -147,7 +148,7 @@ export class TiffConverterStrategy implements ImageConverterStrategy {
break
}
case 16: {
// 16位数据需要转换为8
// 16 位数据,需要转换为 8
const data = sourceData as Uint16Array
targetData[dstIndex] = Math.round((data[srcIndex] || 0) / 257) // R
targetData[dstIndex + 1] =
@@ -165,7 +166,7 @@ export class TiffConverterStrategy implements ImageConverterStrategy {
break
}
case 32: {
// 32位浮点数据
// 32 位浮点数据
const data = sourceData as Float32Array | Float64Array
targetData[dstIndex] = Math.round((data[srcIndex] || 0) * 255) // R
targetData[dstIndex + 1] =

View File

@@ -13,6 +13,7 @@ export interface LoadingState {
loadedBytes?: number
totalBytes?: number
isConverting?: boolean
isQueueWaiting?: boolean
conversionMessage?: string
codecInfo?: string
}

View File

@@ -316,6 +316,7 @@
"loading.default": "Loading",
"loading.heic.converting": "Converting HEIC/HEIF image format...",
"loading.heic.main": "HEIC",
"loading.queue.waiting": "Waiting for available converter...",
"loading.webgl.building": "Building high-quality textures...",
"loading.webgl.main": "WebGL Texture Loading",
"minimap.loading": "Loading map...",
@@ -377,4 +378,4 @@
"video.conversion.webcodecs.not.supported": "WebCodecs is not supported in this browser",
"video.format.mov.not.supported": "Browser does not support MOV format, conversion required",
"video.format.mov.supported": "Browser natively supports MOV format, skipping conversion"
}
}

View File

@@ -41,37 +41,37 @@
"action.view.layout": "レイアウト",
"action.view.settings": "ビュー設定",
"action.view.title": "ビュー",
"date.day.1": "1日",
"date.day.10": "10日",
"date.day.11": "11日",
"date.day.12": "12日",
"date.day.13": "13日",
"date.day.14": "14日",
"date.day.15": "15日",
"date.day.16": "16日",
"date.day.17": "17日",
"date.day.18": "18日",
"date.day.19": "19日",
"date.day.2": "2日",
"date.day.20": "20日",
"date.day.21": "21日",
"date.day.22": "22日",
"date.day.23": "23日",
"date.day.24": "24日",
"date.day.25": "25日",
"date.day.26": "26日",
"date.day.27": "27日",
"date.day.28": "28日",
"date.day.29": "29日",
"date.day.3": "3日",
"date.day.30": "30日",
"date.day.31": "31日",
"date.day.4": "4日",
"date.day.5": "5日",
"date.day.6": "6日",
"date.day.7": "7日",
"date.day.8": "8日",
"date.day.9": "9日",
"date.day.1": "1 日",
"date.day.10": "10 日",
"date.day.11": "11 日",
"date.day.12": "12 日",
"date.day.13": "13 日",
"date.day.14": "14 日",
"date.day.15": "15 日",
"date.day.16": "16 日",
"date.day.17": "17 日",
"date.day.18": "18 日",
"date.day.19": "19 日",
"date.day.2": "2 日",
"date.day.20": "20 日",
"date.day.21": "21 日",
"date.day.22": "22 日",
"date.day.23": "23 日",
"date.day.24": "24 日",
"date.day.25": "25 日",
"date.day.26": "26 日",
"date.day.27": "27 日",
"date.day.28": "28 日",
"date.day.29": "29 日",
"date.day.3": "3 日",
"date.day.30": "30 日",
"date.day.31": "31 日",
"date.day.4": "4 日",
"date.day.5": "5 日",
"date.day.6": "6 日",
"date.day.7": "7 日",
"date.day.8": "8 日",
"date.day.9": "9 日",
"date.month.1": "1月",
"date.month.10": "10月",
"date.month.11": "11月",
@@ -312,6 +312,7 @@
"loading.default": "読み込み中",
"loading.heic.converting": "HEIC/HEIF 画像フォーマットを変換中...",
"loading.heic.main": "HEIC",
"loading.queue.waiting": "変換待機中です...",
"loading.webgl.building": "高品質テクスチャを構築中...",
"loading.webgl.main": "WebGL テクスチャの読み込み",
"minimap.loading": "地図を読み込み中...",
@@ -370,4 +371,4 @@
"video.conversion.webcodecs.not.supported": "このブラウザは WebCodecs をサポートしていません",
"video.format.mov.not.supported": "ブラウザが MOV 形式をサポートしていないため、変換が必要です",
"video.format.mov.supported": "ブラウザが MOV 形式をネイティブでサポートしているため、変換をスキップします"
}
}

View File

@@ -41,37 +41,37 @@
"action.view.layout": "레이아웃",
"action.view.settings": "보기 설정",
"action.view.title": "보기",
"date.day.1": "1일",
"date.day.10": "10일",
"date.day.11": "11일",
"date.day.12": "12일",
"date.day.13": "13일",
"date.day.14": "14일",
"date.day.15": "15일",
"date.day.16": "16일",
"date.day.17": "17일",
"date.day.18": "18일",
"date.day.19": "19일",
"date.day.2": "2일",
"date.day.20": "20일",
"date.day.21": "21일",
"date.day.22": "22일",
"date.day.23": "23일",
"date.day.24": "24일",
"date.day.25": "25일",
"date.day.26": "26일",
"date.day.27": "27일",
"date.day.28": "28일",
"date.day.29": "29일",
"date.day.3": "3일",
"date.day.30": "30일",
"date.day.31": "31일",
"date.day.4": "4일",
"date.day.5": "5일",
"date.day.6": "6일",
"date.day.7": "7일",
"date.day.8": "8일",
"date.day.9": "9일",
"date.day.1": "1 일",
"date.day.10": "10 일",
"date.day.11": "11 일",
"date.day.12": "12 일",
"date.day.13": "13 일",
"date.day.14": "14 일",
"date.day.15": "15 일",
"date.day.16": "16 일",
"date.day.17": "17 일",
"date.day.18": "18 일",
"date.day.19": "19 일",
"date.day.2": "2 일",
"date.day.20": "20 일",
"date.day.21": "21 일",
"date.day.22": "22 일",
"date.day.23": "23 일",
"date.day.24": "24 일",
"date.day.25": "25 일",
"date.day.26": "26 일",
"date.day.27": "27 일",
"date.day.28": "28 일",
"date.day.29": "29 일",
"date.day.3": "3 일",
"date.day.30": "30 일",
"date.day.31": "31 일",
"date.day.4": "4 일",
"date.day.5": "5 일",
"date.day.6": "6 일",
"date.day.7": "7 일",
"date.day.8": "8 일",
"date.day.9": "9 일",
"date.month.1": "1월",
"date.month.10": "10월",
"date.month.11": "11월",
@@ -312,6 +312,7 @@
"loading.default": "로딩 중",
"loading.heic.converting": "HEIC/HEIF 이미지 형식 변환 중...",
"loading.heic.main": "HEIC",
"loading.queue.waiting": "변환 대기 중입니다...",
"loading.webgl.building": "고품질 텍스처 구축 중...",
"loading.webgl.main": "WebGL 텍스처 로딩",
"minimap.loading": "지도 로딩 중...",
@@ -370,4 +371,4 @@
"video.conversion.webcodecs.not.supported": "이 브라우저는 WebCodecs 를 지원하지 않습니다",
"video.format.mov.not.supported": "브라우저가 MOV 형식을 지원하지 않아 변환이 필요합니다.",
"video.format.mov.supported": "브라우저가 MOV 형식을 기본적으로 지원하므로 변환을 건너뜁니다."
}
}

View File

@@ -313,6 +313,7 @@
"loading.default": "加载中",
"loading.heic.converting": "正在转换 HEIC/HEIF 图像格式...",
"loading.heic.main": "HEIC",
"loading.queue.waiting": "正在排队等待转换...",
"loading.webgl.building": "正在构建高质量纹理...",
"loading.webgl.main": "WebGL 纹理加载",
"minimap.loading": "加载地图中...",
@@ -374,4 +375,4 @@
"video.conversion.webcodecs.not.supported": "此浏览器不支持 WebCodecs",
"video.format.mov.not.supported": "浏览器不支持 MOV 格式,需要转换",
"video.format.mov.supported": "浏览器原生支持 MOV 格式,跳过转换"
}
}

View File

@@ -41,37 +41,37 @@
"action.view.layout": "佈局",
"action.view.settings": "檢視設定",
"action.view.title": "檢視",
"date.day.1": "1日",
"date.day.10": "10日",
"date.day.11": "11日",
"date.day.12": "12日",
"date.day.13": "13日",
"date.day.14": "14日",
"date.day.15": "15日",
"date.day.16": "16日",
"date.day.17": "17日",
"date.day.18": "18日",
"date.day.19": "19日",
"date.day.2": "2日",
"date.day.20": "20日",
"date.day.21": "21日",
"date.day.22": "22日",
"date.day.23": "23日",
"date.day.24": "24日",
"date.day.25": "25日",
"date.day.26": "26日",
"date.day.27": "27日",
"date.day.28": "28日",
"date.day.29": "29日",
"date.day.3": "3日",
"date.day.30": "30日",
"date.day.31": "31日",
"date.day.4": "4日",
"date.day.5": "5日",
"date.day.6": "6日",
"date.day.7": "7日",
"date.day.8": "8日",
"date.day.9": "9日",
"date.day.1": "1 日",
"date.day.10": "10 日",
"date.day.11": "11 日",
"date.day.12": "12 日",
"date.day.13": "13 日",
"date.day.14": "14 日",
"date.day.15": "15 日",
"date.day.16": "16 日",
"date.day.17": "17 日",
"date.day.18": "18 日",
"date.day.19": "19 日",
"date.day.2": "2 日",
"date.day.20": "20 日",
"date.day.21": "21 日",
"date.day.22": "22 日",
"date.day.23": "23 日",
"date.day.24": "24 日",
"date.day.25": "25 日",
"date.day.26": "26 日",
"date.day.27": "27 日",
"date.day.28": "28 日",
"date.day.29": "29 日",
"date.day.3": "3 日",
"date.day.30": "30 日",
"date.day.31": "31 日",
"date.day.4": "4 日",
"date.day.5": "5 日",
"date.day.6": "6 日",
"date.day.7": "7 日",
"date.day.8": "8 日",
"date.day.9": "9 日",
"date.month.1": "1月",
"date.month.10": "7月",
"date.month.11": "8月",
@@ -312,6 +312,7 @@
"loading.default": "載入中",
"loading.heic.converting": "正在轉換 HEIC/HEIF 圖像格式...",
"loading.heic.main": "HEIC",
"loading.queue.waiting": "正在排隊等候轉換...",
"loading.webgl.building": "正在建置高品質紋理...",
"loading.webgl.main": "WebGL 紋理載入",
"minimap.loading": "載入地圖中...",
@@ -370,4 +371,4 @@
"video.conversion.webcodecs.not.supported": "此瀏覽器不支援 WebCodecs",
"video.format.mov.not.supported": "瀏覽器不支援 MOV 格式,需要轉換",
"video.format.mov.supported": "瀏覽器原生支援 MOV 格式,跳過轉換"
}
}

View File

@@ -41,37 +41,37 @@
"action.view.layout": "佈局",
"action.view.settings": "檢視設定",
"action.view.title": "檢視",
"date.day.1": "1日",
"date.day.10": "10日",
"date.day.11": "11日",
"date.day.12": "12日",
"date.day.13": "13日",
"date.day.14": "14日",
"date.day.15": "15日",
"date.day.16": "16日",
"date.day.17": "17日",
"date.day.18": "18日",
"date.day.19": "19日",
"date.day.2": "2日",
"date.day.20": "20日",
"date.day.21": "21日",
"date.day.22": "22日",
"date.day.23": "23日",
"date.day.24": "24日",
"date.day.25": "25日",
"date.day.26": "26日",
"date.day.27": "27日",
"date.day.28": "28日",
"date.day.29": "29日",
"date.day.3": "3日",
"date.day.30": "30日",
"date.day.31": "31日",
"date.day.4": "4日",
"date.day.5": "5日",
"date.day.6": "6日",
"date.day.7": "7日",
"date.day.8": "8日",
"date.day.9": "9日",
"date.day.1": "1 日",
"date.day.10": "10 日",
"date.day.11": "11 日",
"date.day.12": "12 日",
"date.day.13": "13 日",
"date.day.14": "14 日",
"date.day.15": "15 日",
"date.day.16": "16 日",
"date.day.17": "17 日",
"date.day.18": "18 日",
"date.day.19": "19 日",
"date.day.2": "2 日",
"date.day.20": "20 日",
"date.day.21": "21 日",
"date.day.22": "22 日",
"date.day.23": "23 日",
"date.day.24": "24 日",
"date.day.25": "25 日",
"date.day.26": "26 日",
"date.day.27": "27 日",
"date.day.28": "28 日",
"date.day.29": "29 日",
"date.day.3": "3 日",
"date.day.30": "30 日",
"date.day.31": "31 日",
"date.day.4": "4 日",
"date.day.5": "5 日",
"date.day.6": "6 日",
"date.day.7": "7 日",
"date.day.8": "8 日",
"date.day.9": "9 日",
"date.month.1": "1月",
"date.month.10": "10月",
"date.month.11": "11月",
@@ -311,6 +311,7 @@
"loading.default": "載入中",
"loading.heic.converting": "正在轉換 HEIC/HEIF 圖像格式...",
"loading.heic.main": "HEIC",
"loading.queue.waiting": "正在排隊等待轉換...",
"loading.webgl.building": "正在建置高品質紋理...",
"loading.webgl.main": "WebGL 紋理載入",
"minimap.loading": "載入地圖中...",
@@ -369,4 +370,4 @@
"video.conversion.webcodecs.not.supported": "此瀏覽器不支援 WebCodecs",
"video.format.mov.not.supported": "瀏覽器不支援 MOV 格式,需要轉換",
"video.format.mov.supported": "瀏覽器原生支援 MOV 格式,跳過轉換"
}
}