/** * 图片透明区域探测:用于后台在上传「热区按钮图 / 海报底图」时提示 * 「这张图其实没有透明区域」——最常见的坑是把设计工具里带棋盘格的预览 * 截图/另存下来,棋盘格就成了真实像素,前台会照实显示。 */ 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() 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 { 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 { if (typeof createImageBitmap === 'function') { try { return await createImageBitmap(file) } catch { // 继续走 兜底 } } 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 }) }