diff --git a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx index f6cb9389..0dcf3b44 100644 --- a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx +++ b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx @@ -49,7 +49,6 @@ export const ExifPanel: FC<{ const imageFormat = getImageFormat( currentPhoto.originalUrl || currentPhoto.s3Key || '', ) - const totalPixels = (currentPhoto.height * currentPhoto.width) / 1000000 return ( - {totalPixels && ( + {formattedExifData?.megaPixels && ( )} {formattedExifData?.colorSpace && ( diff --git a/packages/webgl-viewer/src/WebGLImageViewerEngine.ts b/packages/webgl-viewer/src/WebGLImageViewerEngine.ts index 49335f14..5b42d25c 100644 --- a/packages/webgl-viewer/src/WebGLImageViewerEngine.ts +++ b/packages/webgl-viewer/src/WebGLImageViewerEngine.ts @@ -10,16 +10,8 @@ import TextureWorkerRaw from './texture.worker?raw' // 瓦片系统配置 const TILE_SIZE = 512 // 每个瓦片的像素大小 -// 平台调优:iOS 设备上降低并发和缓存以减少内存峰值 -function isIOSPlatform(): boolean { - if (typeof navigator === 'undefined') return false - const ua = navigator.userAgent || '' - return /iP(?:ad|hone|od)/.test(ua) -} - -// 作为实例字段使用的默认阈值(避免在SSR时访问window) -const DEFAULT_MAX_TILES_PER_FRAME = 4 -const DEFAULT_TILE_CACHE_SIZE = 32 +const MAX_TILES_PER_FRAME = 4 // 每帧最多创建的瓦片数量 +const TILE_CACHE_SIZE = 32 // 最大缓存瓦片数量 // 瓦片信息接口 interface TileInfo { @@ -87,7 +79,6 @@ export class WebGLImageViewerEngine extends ImageViewerEngineBase { private targetTranslateX = 0 private targetTranslateY = 0 private animationStartLOD = -1 - // 动画焦点(已回滚到默认行为,无额外 pivot 锁定状态) // 简化的纹理管理 private currentLOD = 1 // 默认使用正常质量 @@ -110,10 +101,6 @@ export class WebGLImageViewerEngine extends ImageViewerEngineBase { private worker: Worker | null = null private textureWorkerInitialized = false - // 平台相关的瓦片参数 - private MAX_TILES_PER_FRAME = DEFAULT_MAX_TILES_PER_FRAME - private TILE_CACHE_SIZE = DEFAULT_TILE_CACHE_SIZE - // 事件处理器绑定 private boundHandleMouseDown: (e: MouseEvent) => void private boundHandleMouseMove: (e: MouseEvent) => void @@ -154,8 +141,7 @@ export class WebGLImageViewerEngine extends ImageViewerEngineBase { // 初始化 WebGL const gl = canvas.getContext('webgl', { alpha: true, - // 使用预乘 alpha,避免在与页面合成时出现 1px 边缘伪影 - premultipliedAlpha: true, + premultipliedAlpha: false, antialias: true, powerPreference: 'default', }) @@ -180,12 +166,6 @@ export class WebGLImageViewerEngine extends ImageViewerEngineBase { this.initWorker() this.setupEventListeners() - // 初始化平台相关阈值 - if (isIOSPlatform()) { - this.MAX_TILES_PER_FRAME = 2 - this.TILE_CACHE_SIZE = 16 - } - this.isLoadingTexture = false this.notifyLoadingStateChange(false) } @@ -207,9 +187,7 @@ export class WebGLImageViewerEngine extends ImageViewerEngineBase { private resizeCanvas() { const rect = this.canvas.getBoundingClientRect() - // 限制 iOS 上的 DPR 以减少渲染分辨率和显存压力 - const dpr = window.devicePixelRatio || 1 - this.devicePixelRatio = isIOSPlatform() ? Math.min(dpr, 2) : dpr + this.devicePixelRatio = window.devicePixelRatio || 1 this.canvasWidth = rect.width this.canvasHeight = rect.height @@ -257,24 +235,9 @@ export class WebGLImageViewerEngine extends ImageViewerEngineBase { gl.useProgram(this.program) - // 启用混合(针对预乘 alpha 使用正确的混合函数) + // 启用混合 gl.enable(gl.BLEND) - gl.blendFuncSeparate( - gl.ONE, - gl.ONE_MINUS_SRC_ALPHA, - gl.ONE, - gl.ONE_MINUS_SRC_ALPHA, - ) - - // 上传像素时的解包设置,避免多余的颜色空间转换和预乘 - gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0) - gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 0) - // 避免色彩空间转换导致的额外开销 - // @ts-ignore - 常量属于扩展 - if (gl.UNPACK_COLORSPACE_CONVERSION_WEBGL !== undefined) { - // @ts-ignore - gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE) - } + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) // 创建几何体 const positions = new Float32Array([ @@ -503,25 +466,20 @@ export class WebGLImageViewerEngine extends ImageViewerEngineBase { // 寻找最佳的 LOD 级别 // 我们希望找到一个 LOD 级别,它的缩放比例刚好大于或等于所需的缩放比例 - const maxLodIndex = isIOSPlatform() - ? SIMPLE_LOD_LEVELS.findIndex((l) => l.scale >= 1) // 限制 iOS 最高 LOD = 1x - : SIMPLE_LOD_LEVELS.length - 1 - for (let i = 0; i <= SIMPLE_LOD_LEVELS.length - 1; i++) { - const level = SIMPLE_LOD_LEVELS[i] - if (level.scale >= requiredScale) { - return Math.min(i, maxLodIndex) + for (const [i, SIMPLE_LOD_LEVEL] of SIMPLE_LOD_LEVELS.entries()) { + if (SIMPLE_LOD_LEVEL.scale >= requiredScale) { + return i } } // 如果没有找到,返回最高质量的 LOD - return Math.min(SIMPLE_LOD_LEVELS.length - 1, maxLodIndex) + return SIMPLE_LOD_LEVELS.length - 1 } // 缓动函数 private easeOutQuart(t: number): number { return 1 - Math.pow(1 - t, 4) } - // 回滚:保持原始的 easeOutQuart 作为平滑动画曲线 private startAnimation( targetScale: number, @@ -829,11 +787,11 @@ export class WebGLImageViewerEngine extends ImageViewerEngineBase { const maxAge = 30000 // 30 秒后清理不再使用的瓦片 // 如果缓存过大,强制清理 - if (this.tileCache.size > this.TILE_CACHE_SIZE) { + if (this.tileCache.size > TILE_CACHE_SIZE) { const tilesToRemove = Array.from(this.tileCache.entries()) .filter(([key]) => !this.currentVisibleTiles.has(key)) .sort(([, a], [, b]) => a.lastUsed - b.lastUsed) - .slice(0, this.tileCache.size - this.TILE_CACHE_SIZE + 5) // 清理多一些 + .slice(0, this.tileCache.size - TILE_CACHE_SIZE + 5) // 清理多一些 for (const [key, tileInfo] of tilesToRemove) { if (tileInfo.texture) { @@ -870,7 +828,7 @@ export class WebGLImageViewerEngine extends ImageViewerEngineBase { this.pendingTileRequests.sort((a, b) => a.priority - b.priority) // 限制并发加载数量 - const batch = this.pendingTileRequests.splice(0, this.MAX_TILES_PER_FRAME) + const batch = this.pendingTileRequests.splice(0, MAX_TILES_PER_FRAME) for (const request of batch) { const { key, priority } = request @@ -1095,8 +1053,8 @@ export class WebGLImageViewerEngine extends ImageViewerEngineBase { visibleTiles: this.currentVisibleTiles.size, loadingTiles: this.loadingTiles.size, pendingRequests: this.pendingTileRequests.length, - cacheLimit: this.TILE_CACHE_SIZE, - maxTilesPerFrame: this.MAX_TILES_PER_FRAME, + cacheLimit: TILE_CACHE_SIZE, + maxTilesPerFrame: MAX_TILES_PER_FRAME, tileSize: TILE_SIZE, cacheKeys: Array.from(this.tileCache.keys()), visibleKeys: Array.from(this.currentVisibleTiles), diff --git a/packages/webgl-viewer/src/texture.worker.js b/packages/webgl-viewer/src/texture.worker.js index 14c66e7c..7b2f1219 100644 --- a/packages/webgl-viewer/src/texture.worker.js +++ b/packages/webgl-viewer/src/texture.worker.js @@ -1,25 +1,7 @@ // @ts-nocheck /// -// Keep only the original image Blob and parsed dimensions to avoid -// holding a decoded 100MP ImageBitmap in memory (iOS Safari crash risk) -let originalBlob = null -let originalWidth = 0 -let originalHeight = 0 -let supportsRegionDecode = false -let baseBitmap = null // fallback decoded, downscaled whole image - -function isIOS() { - try { - // In workers, navigator.userAgent is available - const ua = (self.navigator && self.navigator.userAgent) || '' - return /iP(?:ad|hone|od)/.test(ua) - } catch { - return false - } -} - -const MAX_BASE_DECODE_DIM = isIOS() ? 4096 : 8192 +let originalImage = null const TILE_SIZE = 512 // Must be same as in WebGLImageViewerEngine.ts @@ -38,99 +20,46 @@ const WORKER_SIMPLE_LOD_LEVELS = [ */ self.onmessage = async (e) => { const { type, payload } = e.data - console.info('[Worker] Received message:', type) + console.info('[Worker] Received message:', type, payload) switch (type) { case 'load-image': { const { url } = payload try { + console.info('[Worker] Fetching image:', url) const response = await fetch(url, { mode: 'cors' }) const blob = await response.blob() + originalImage = await createImageBitmap(blob) - // Parse image dimensions from headers to avoid decoding full image - const { width, height } = await getImageSizeFromBlob(blob) - if (!width || !height) { - // Fallback: if we cannot parse, last resort decode just to get size - // Note: this may be heavy; try to downscale aggressively - const probe = await createImageBitmap(blob, { - resizeWidth: 64, - resizeHeight: 64, - }) - // Width/height unknown from probe; we must bail with an error - // and let main thread handle sizing. - probe.close() - throw new Error('Unsupported image format for header parsing') - } - - originalBlob = blob - originalWidth = width - originalHeight = height - - // Feature detect region decode - supportsRegionDecode = true - try { - const test = await createImageBitmap(originalBlob, 0, 0, 1, 1) - test.close() - } catch { - supportsRegionDecode = false - } - - // If region decode is not supported, prepare a downscaled base bitmap - if (!supportsRegionDecode) { - const scale = Math.min( - 1, - MAX_BASE_DECODE_DIM / Math.max(width, height), - ) - const decW = Math.max(1, Math.round(width * scale)) - const decH = Math.max(1, Math.round(height * scale)) - baseBitmap = await createImageBitmap(originalBlob, { - resizeWidth: decW, - resizeHeight: decH, - resizeQuality: 'medium', - }) - } - + console.info('[Worker] Image decoded, posting init-done') self.postMessage({ type: 'init-done' }) - // Create a small initial LOD bitmap directly from Blob or baseBitmap - const lodLevel = 1 // initial medium LOD + // Create initial LOD texture + const lodLevel = 1 // Initial LOD level const lodConfig = WORKER_SIMPLE_LOD_LEVELS[lodLevel] - const finalWidth = Math.max(1, Math.round(width * lodConfig.scale)) - const finalHeight = Math.max(1, Math.round(height * lodConfig.scale)) + const finalWidth = Math.max( + 1, + Math.round(originalImage.width * lodConfig.scale), + ) + const finalHeight = Math.max( + 1, + Math.round(originalImage.height * lodConfig.scale), + ) - let initialLODBitmap - if (supportsRegionDecode) { - initialLODBitmap = await createImageBitmap(originalBlob, { - resizeWidth: finalWidth, - resizeHeight: finalHeight, - resizeQuality: 'medium', - }) - } else if (baseBitmap) { - // Scale baseBitmap again to the target LOD size - // Use OffscreenCanvas if available, otherwise createImageBitmap(baseBitmap) - if (typeof OffscreenCanvas !== 'undefined') { - const canvas = new OffscreenCanvas(finalWidth, finalHeight) - const ctx = canvas.getContext('2d') - ctx.imageSmoothingEnabled = true - ctx.imageSmoothingQuality = 'medium' - ctx.drawImage(baseBitmap, 0, 0, finalWidth, finalHeight) - initialLODBitmap = canvas.transferToImageBitmap() - } else { - initialLODBitmap = await createImageBitmap(baseBitmap, { - resizeWidth: finalWidth, - resizeHeight: finalHeight, - resizeQuality: 'medium', - }) - } - } + const initialLODBitmap = await createImageBitmap(originalImage, { + resizeWidth: finalWidth, + resizeHeight: finalHeight, + resizeQuality: 'medium', + }) + console.info('[Worker] Initial LOD created, posting image-loaded') self.postMessage( { type: 'image-loaded', payload: { imageBitmap: initialLODBitmap, - imageWidth: width, - imageHeight: height, + imageWidth: originalImage.width, + imageHeight: originalImage.height, lodLevel, }, }, @@ -138,21 +67,18 @@ self.onmessage = async (e) => { ) } catch (error) { console.error('[Worker] Error loading image:', error) - self.postMessage({ - type: 'load-error', - payload: { error: String(error) }, - }) + self.postMessage({ type: 'load-error', payload: { error } }) } break } case 'init': { - // Deprecated path; keep for compatibility + originalImage = payload.imageBitmap self.postMessage({ type: 'init-done' }) break } case 'create-tile': { - if (!originalBlob) { - console.warn('Worker has not been initialized with a Blob.') + if (!originalImage) { + console.warn('Worker has not been initialized with an image.') return } @@ -167,16 +93,15 @@ self.onmessage = async (e) => { lodConfig, ) - // Compute source rect at original resolution for this tile - const srcW = imageWidth / cols - const srcH = imageHeight / rows - const sourceX = Math.floor(x * srcW) - const sourceY = Math.floor(y * srcH) + // Calculate tile region in the original image + const sourceWidth = imageWidth / cols + const sourceHeight = imageHeight / rows // Assuming square tiles from a square grid on the image + const sourceX = x * sourceWidth + const sourceY = y * sourceHeight - const actualSourceWidth = Math.min(srcW, imageWidth - sourceX) - const actualSourceHeight = Math.min(srcH, imageHeight - sourceY) + const actualSourceWidth = Math.min(sourceWidth, imageWidth - sourceX) + const actualSourceHeight = Math.min(sourceHeight, imageHeight - sourceY) - // Target tile size remains constant (<= TILE_SIZE) const targetWidth = Math.min( TILE_SIZE, Math.ceil(actualSourceWidth * lodConfig.scale), @@ -190,77 +115,33 @@ self.onmessage = async (e) => { return } - // Decode only the needed region, resized to the tile target size - let imageBitmap - if (supportsRegionDecode) { - imageBitmap = await createImageBitmap( - originalBlob, - sourceX, - sourceY, - actualSourceWidth, - actualSourceHeight, - { - resizeWidth: targetWidth, - resizeHeight: targetHeight, - resizeQuality: lodConfig.scale >= 1 ? 'high' : 'medium', - }, - ) - } else if (baseBitmap) { - // Crop from baseBitmap using canvas (preferred) or ImageBitmap crop if supported - if (typeof OffscreenCanvas !== 'undefined') { - const canvas = new OffscreenCanvas(targetWidth, targetHeight) - const ctx = canvas.getContext('2d') - ctx.imageSmoothingEnabled = true - ctx.imageSmoothingQuality = lodConfig.scale >= 1 ? 'high' : 'medium' + // Use OffscreenCanvas to draw the tile + const canvas = new OffscreenCanvas(targetWidth, targetHeight) + const ctx = canvas.getContext('2d') - const scaleX = baseBitmap.width / originalWidth - const scaleY = baseBitmap.height / originalHeight + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = lodConfig.scale >= 1 ? 'high' : 'medium' - ctx.drawImage( - baseBitmap, - Math.floor(sourceX * scaleX), - Math.floor(sourceY * scaleY), - Math.ceil(actualSourceWidth * scaleX), - Math.ceil(actualSourceHeight * scaleY), - 0, - 0, - targetWidth, - targetHeight, - ) - imageBitmap = canvas.transferToImageBitmap() - } else { - // Last resort: try cropping via createImageBitmap on baseBitmap - // Safari support may vary; wrap in try/catch - try { - const scaleX = baseBitmap.width / originalWidth - const scaleY = baseBitmap.height / originalHeight - imageBitmap = await createImageBitmap( - baseBitmap, - Math.floor(sourceX * scaleX), - Math.floor(sourceY * scaleY), - Math.ceil(actualSourceWidth * scaleX), - Math.ceil(actualSourceHeight * scaleY), - { - resizeWidth: targetWidth, - resizeHeight: targetHeight, - resizeQuality: lodConfig.scale >= 1 ? 'high' : 'medium', - }, - ) - } catch (e) { - throw new Error(`Tile crop fallback failed: ${e}`) - } - } - } + ctx.drawImage( + originalImage, + sourceX, + sourceY, + actualSourceWidth, + actualSourceHeight, + 0, + 0, + targetWidth, + targetHeight, + ) + + const imageBitmap = canvas.transferToImageBitmap() self.postMessage( { type: 'tile-created', payload: { key, imageBitmap, lodLevel } }, [imageBitmap], ) } catch (error) { console.error('Error creating tile in worker:', error) - self.postMessage({ - type: 'tile-error', - payload: { key, error: String(error) }, - }) + self.postMessage({ type: 'tile-error', payload: { key, error } }) } break } @@ -284,90 +165,3 @@ function getTileGridSize(imageWidth, imageHeight, _lodLevel, lodConfig) { return { cols, rows } } - -// Read image size from Blob without fully decoding (supports PNG, JPEG, WebP VP8X) -async function getImageSizeFromBlob(blob) { - // Helper to read a range from the Blob - const read = async (start, length) => { - const buf = await blob.slice(start, start + length).arrayBuffer() - return new DataView(buf) - } - - // Read first 32 bytes to detect type - const head = await read(0, 64) - - // PNG: signature 89 50 4E 47 0D 0A 1A 0A, IHDR at offset 8 - if ( - head.getUint32(0, false) === 0x89504e47 && - head.getUint32(4, false) === 0x0d0a1a0a - ) { - // IHDR chunk starts at 8, next 4 bytes length, then 'IHDR' - // IHDR data: width(4), height(4) big-endian - const ihdr = await read(16, 8) - const width = ihdr.getUint32(0, false) - const height = ihdr.getUint32(4, false) - return { width, height } - } - - // JPEG: SOI 0xFFD8, then scan markers to SOF0/2 etc. - if (head.getUint16(0, false) === 0xffd8) { - let offset = 2 - const maxScan = Math.min(blob.size, 1 << 20) // scan up to 1MB - while (offset < maxScan) { - const dv = await read(offset, 4) - if (dv.getUint8(0) !== 0xff) break - const marker = dv.getUint8(1) - const size = dv.getUint16(2, false) - if ( - marker === 0xc0 || // SOF0 - marker === 0xc1 || // SOF1 - marker === 0xc2 || // SOF2 - marker === 0xc3 || - marker === 0xc5 || - marker === 0xc6 || - marker === 0xc7 || - marker === 0xc9 || - marker === 0xca || - marker === 0xcb || - marker === 0xcd || - marker === 0xce || - marker === 0xcf - ) { - const sof = await read(offset + 5, 4) - const height = sof.getUint16(0, false) - const width = sof.getUint16(2, false) - return { width, height } - } - offset += 2 + size - } - } - - // WebP: RIFF, WEBP, VP8X chunk with canvas size - if (head.getUint32(0, true) === 0x46464952 /* RIFF */) { - const webpTag = await read(8, 4) - if (webpTag.getUint32(0, false) === 0x57454250 /* 'WEBP' */) { - // Read chunk header at 12 - const chunkHead = await read(12, 8) - const chunkFourCC = chunkHead.getUint32(0, false) - - // 'VP8X' - if (chunkFourCC === 0x56503858) { - const vp8x = await read(20, 10) - // width-1 in 24 bits at bytes 4..6, height-1 at bytes 7..9, little-endian - const w = - 1 + - (vp8x.getUint8(4) | - (vp8x.getUint8(5) << 8) | - (vp8x.getUint8(6) << 16)) - const h = - 1 + - (vp8x.getUint8(7) | - (vp8x.getUint8(8) << 8) | - (vp8x.getUint8(9) << 16)) - return { width: w, height: h } - } - } - } - - return { width: 0, height: 0 } -}