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