Revert "perf: image webgl perf"

This reverts commit 444468b862.
This commit is contained in:
Innei
2025-09-21 18:29:31 +08:00
parent 84b6c0f438
commit d98049ded6
3 changed files with 72 additions and 319 deletions

View File

@@ -49,7 +49,6 @@ export const ExifPanel: FC<{
const imageFormat = getImageFormat(
currentPhoto.originalUrl || currentPhoto.s3Key || '',
)
const totalPixels = (currentPhoto.height * currentPhoto.width) / 1000000
return (
<m.div
@@ -115,10 +114,12 @@ export const ExifPanel: FC<{
label={t('exif.file.size')}
value={`${(currentPhoto.size / 1024 / 1024).toFixed(1)}MB`}
/>
{totalPixels && (
{formattedExifData?.megaPixels && (
<Row
label={t('exif.pixels')}
value={`${Math.floor(totalPixels)} MP`}
value={`${Math.floor(
Number.parseFloat(formattedExifData.megaPixels),
)} MP`}
/>
)}
{formattedExifData?.colorSpace && (

View File

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

View File

@@ -1,25 +1,7 @@
// @ts-nocheck
/// <reference lib="webworker" />
// 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 }
}