107 lines
3.6 KiB
TypeScript
107 lines
3.6 KiB
TypeScript
/**
|
||
* 图片透明区域探测:用于后台在上传「热区按钮图 / 海报底图」时提示
|
||
* 「这张图其实没有透明区域」——最常见的坑是把设计工具里带棋盘格的预览
|
||
* 截图/另存下来,棋盘格就成了真实像素,前台会照实显示。
|
||
*/
|
||
|
||
export interface ImageTransparencyProbe {
|
||
/** 是否存在透明像素(alpha < 250) */
|
||
hasTransparentPixels: boolean
|
||
/** 透明像素占比(0-1) */
|
||
transparentRatio: number
|
||
/** 不透明像素是否只有极少数浅灰/白颜色(棋盘格或纯色底烘焙进图片的典型特征) */
|
||
suspectedBakedBackdrop: boolean
|
||
}
|
||
|
||
const SAMPLE_MAX_SIDE = 160
|
||
/** alpha 低于该值即视为透明像素 */
|
||
const TRANSPARENT_ALPHA = 250
|
||
/** 无明显透明像素的判定阈值(透明像素占比) */
|
||
const NEGLIGIBLE_TRANSPARENT_RATIO = 0.002
|
||
|
||
/** 纯像素分析(无 DOM 依赖,便于单测)。 */
|
||
export function analyzeImagePixels(data: Uint8ClampedArray | number[]): ImageTransparencyProbe {
|
||
const total = Math.floor(data.length / 4)
|
||
if (total <= 0) {
|
||
return { hasTransparentPixels: false, transparentRatio: 0, suspectedBakedBackdrop: false }
|
||
}
|
||
|
||
let transparent = 0
|
||
let opaque = 0
|
||
let lightOnly = true
|
||
const colors = new Set<number>()
|
||
|
||
for (let index = 0; index < total; index += 1) {
|
||
const offset = index * 4
|
||
const r = data[offset] ?? 0
|
||
const g = data[offset + 1] ?? 0
|
||
const b = data[offset + 2] ?? 0
|
||
const a = data[offset + 3] ?? 0
|
||
if (a < TRANSPARENT_ALPHA) {
|
||
transparent += 1
|
||
continue
|
||
}
|
||
opaque += 1
|
||
const min = Math.min(r, g, b)
|
||
const max = Math.max(r, g, b)
|
||
if (min < 216 || max - min > 28) lightOnly = false
|
||
// 量化到 4bit/通道,避免抗锯齿噪点导致颜色数虚高
|
||
colors.add(((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4))
|
||
}
|
||
|
||
const transparentRatio = transparent / total
|
||
return {
|
||
hasTransparentPixels: transparentRatio > NEGLIGIBLE_TRANSPARENT_RATIO,
|
||
transparentRatio,
|
||
suspectedBakedBackdrop:
|
||
opaque > 0 &&
|
||
lightOnly &&
|
||
transparentRatio <= NEGLIGIBLE_TRANSPARENT_RATIO &&
|
||
colors.size <= 4,
|
||
}
|
||
}
|
||
|
||
/** 读取本地文件像素并分析;环境不支持时返回 null(不阻塞上传)。 */
|
||
export async function probeImageTransparency(file: File): Promise<ImageTransparencyProbe | null> {
|
||
const source = await loadImageSource(file)
|
||
if (!source) return null
|
||
const width = Math.max(1, Math.round(source.width))
|
||
const height = Math.max(1, Math.round(source.height))
|
||
const scale = Math.min(1, SAMPLE_MAX_SIDE / Math.max(width, height))
|
||
const targetWidth = Math.max(1, Math.round(width * scale))
|
||
const targetHeight = Math.max(1, Math.round(height * scale))
|
||
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = targetWidth
|
||
canvas.height = targetHeight
|
||
const context = canvas.getContext('2d')
|
||
if (!context) return null
|
||
context.clearRect(0, 0, targetWidth, targetHeight)
|
||
context.drawImage(source as CanvasImageSource, 0, 0, targetWidth, targetHeight)
|
||
const imageData = context.getImageData(0, 0, targetWidth, targetHeight)
|
||
return analyzeImagePixels(imageData.data)
|
||
}
|
||
|
||
async function loadImageSource(file: File): Promise<ImageBitmap | HTMLImageElement | null> {
|
||
if (typeof createImageBitmap === 'function') {
|
||
try {
|
||
return await createImageBitmap(file)
|
||
} catch {
|
||
// 继续走 <img> 兜底
|
||
}
|
||
}
|
||
return await new Promise(resolve => {
|
||
const url = URL.createObjectURL(file)
|
||
const image = new Image()
|
||
image.onload = () => {
|
||
URL.revokeObjectURL(url)
|
||
resolve(image)
|
||
}
|
||
image.onerror = () => {
|
||
URL.revokeObjectURL(url)
|
||
resolve(null)
|
||
}
|
||
image.src = url
|
||
})
|
||
}
|