diff --git a/apps/web/src/components/ui/photo-viewer/LoadingIndicator.tsx b/apps/web/src/components/ui/photo-viewer/LoadingIndicator.tsx index aedff0c9..2d3aa1d6 100644 --- a/apps/web/src/components/ui/photo-viewer/LoadingIndicator.tsx +++ b/apps/web/src/components/ui/photo-viewer/LoadingIndicator.tsx @@ -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 = ({ // 视频转换状态 <>

- {loadingState.conversionMessage || t('loading.converting')} + {loadingState.isQueueWaiting + ? loadingState.conversionMessage || t('loading.queue.waiting') + : loadingState.conversionMessage || t('loading.converting')}

) : loadingState.isWebGLLoading ? ( diff --git a/apps/web/src/lib/image-convert/index.ts b/apps/web/src/lib/image-convert/index.ts index 1ecbc446..55fa4b1d 100644 --- a/apps/web/src/lib/image-convert/index.ts +++ b/apps/web/src/lib/image-convert/index.ts @@ -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() + private readonly conversionPipeline: ImageConversionPipeline + private readonly pendingConversions = new Map< + string, + Promise + >() - 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}` + } } // 导出单例实例 diff --git a/apps/web/src/lib/image-convert/pipeline.ts b/apps/web/src/lib/image-convert/pipeline.ts new file mode 100644 index 00000000..76ea9fc2 --- /dev/null +++ b/apps/web/src/lib/image-convert/pipeline.ts @@ -0,0 +1,90 @@ +export interface PipelineOptions { + maxConcurrent?: number +} + +type TaskExecutor = () => Promise + +type QueueTask = { + execute: TaskExecutor + 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> = [] + private activeCount = 0 + + constructor(options: PipelineOptions = {}) { + const { maxConcurrent = 2 } = options + this.maxConcurrent = Math.max(1, maxConcurrent) + } + + enqueue(task: TaskExecutor): Promise { + return new Promise((resolve, reject) => { + const queueTask: QueueTask = { + 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() + } + })() + } +} diff --git a/apps/web/src/lib/image-convert/strategies/heic.ts b/apps/web/src/lib/image-convert/strategies/heic.ts index 868ce2a8..8a526528 100644 --- a/apps/web/src/lib/image-convert/strategies/heic.ts +++ b/apps/web/src/lib/image-convert/strategies/heic.ts @@ -42,6 +42,7 @@ export class HeicConverterStrategy implements ImageConverterStrategy { // 更新转换状态 onLoadingStateUpdate?.({ isConverting: true, + isQueueWaiting: false, conversionMessage: i18n.t('loading.heic.converting'), isHeicFormat: true, loadingProgress: 100, diff --git a/apps/web/src/lib/image-convert/strategies/tiff.ts b/apps/web/src/lib/image-convert/strategies/tiff.ts index 526daf4e..267dad44 100644 --- a/apps/web/src/lib/image-convert/strategies/tiff.ts +++ b/apps/web/src/lib/image-convert/strategies/tiff.ts @@ -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] = diff --git a/apps/web/src/lib/image-loader-manager.ts b/apps/web/src/lib/image-loader-manager.ts index e4099c3f..8525f73c 100644 --- a/apps/web/src/lib/image-loader-manager.ts +++ b/apps/web/src/lib/image-loader-manager.ts @@ -13,6 +13,7 @@ export interface LoadingState { loadedBytes?: number totalBytes?: number isConverting?: boolean + isQueueWaiting?: boolean conversionMessage?: string codecInfo?: string } diff --git a/locales/app/en.json b/locales/app/en.json index 4f946d38..600f2a99 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -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" -} +} \ No newline at end of file diff --git a/locales/app/jp.json b/locales/app/jp.json index 582620ba..feeb03b2 100644 --- a/locales/app/jp.json +++ b/locales/app/jp.json @@ -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 形式をネイティブでサポートしているため、変換をスキップします" -} +} \ No newline at end of file diff --git a/locales/app/ko.json b/locales/app/ko.json index 48c38af4..959e8acd 100644 --- a/locales/app/ko.json +++ b/locales/app/ko.json @@ -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 형식을 기본적으로 지원하므로 변환을 건너뜁니다." -} +} \ No newline at end of file diff --git a/locales/app/zh-CN.json b/locales/app/zh-CN.json index 736975a3..78affb41 100644 --- a/locales/app/zh-CN.json +++ b/locales/app/zh-CN.json @@ -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 格式,跳过转换" -} +} \ No newline at end of file diff --git a/locales/app/zh-HK.json b/locales/app/zh-HK.json index dc6ef7e6..9d992fca 100644 --- a/locales/app/zh-HK.json +++ b/locales/app/zh-HK.json @@ -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 格式,跳過轉換" -} +} \ No newline at end of file diff --git a/locales/app/zh-TW.json b/locales/app/zh-TW.json index c67b6920..2fc4110d 100644 --- a/locales/app/zh-TW.json +++ b/locales/app/zh-TW.json @@ -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 格式,跳過轉換" -} +} \ No newline at end of file