forked from natuka/web.puabadge.com
修改:优化手机端效果图点云效果
This commit is contained in:
1 parent
605bd894c5
commit
5892c47a6b
5 files changed
+132
-43
No files matched your search
+5
-1
@@ -4,7 +4,11 @@ const PREFIX = 'douyin'
|
|||||||
|
|
||||||
function normalizeOssUrl(url: string) {
|
function normalizeOssUrl(url: string) {
|
||||||
if (!url) return ''
|
if (!url) return ''
|
||||||
return String(url).replace(/\\u0026/gi, '&').replace(/\\u003d/gi, '=')
|
let s = String(url).replace(/\\u0026/gi, '&').replace(/\\u003d/gi, '=').trim()
|
||||||
|
// 真机 https 页加载 http 图会被拦截,统一升 https
|
||||||
|
if (s.indexOf('//') === 0) s = `https:${s}`
|
||||||
|
if (/^http:\/\//i.test(s)) s = `https://${s.slice(7)}`
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
function guessContentType(file: File | Blob, filename?: string) {
|
function guessContentType(file: File | Blob, filename?: string) {
|
||||||
|
|||||||
@@ -186,9 +186,9 @@ function preloadResult(url: string) {
|
|||||||
if (!url || !renderer || !sm) return
|
if (!url || !renderer || !sm) return
|
||||||
resultKey = url
|
resultKey = url
|
||||||
sm.setResultPreloadStatus('loading')
|
sm.setResultPreloadStatus('loading')
|
||||||
// 效果图保留完整画面做点云(去背景会在手机上采空,表现为「没有效果图点云」)
|
// 效果图点云去掉背景,只保留主体
|
||||||
renderer.textures
|
renderer.textures
|
||||||
.load(url, { removeBackground: false })
|
.load(url, { removeBackground: true })
|
||||||
.then((entry: any) => {
|
.then((entry: any) => {
|
||||||
if (destroyed || !sm) return
|
if (destroyed || !sm) return
|
||||||
if (!entry || !entry.pointCloudBuffer) {
|
if (!entry || !entry.pointCloudBuffer) {
|
||||||
@@ -428,14 +428,16 @@ onUnmounted(() => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 仅占位;真实采样已改用离屏 canvas,勿再用 CSS 锁尺寸 */
|
||||||
.ipc-scale {
|
.ipc-scale {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
width: 1px !important;
|
left: -9999px !important;
|
||||||
height: 1px !important;
|
top: 0 !important;
|
||||||
|
width: 2px;
|
||||||
|
height: 2px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
left: -9999px !important;
|
visibility: hidden;
|
||||||
top: auto !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ipc-fallback {
|
.ipc-fallback {
|
||||||
|
|||||||
@@ -46,11 +46,11 @@ function createPointCloudSampler(scaleCanvas, viewportW, viewportH, sharedCtx) {
|
|||||||
const forceSampleSize = options.forceSampleSize || null
|
const forceSampleSize = options.forceSampleSize || null
|
||||||
|
|
||||||
const image = entry.image
|
const image = entry.image
|
||||||
if (!image || !image.width || !image.height) {
|
const srcW = (image && (image.naturalWidth || image.width)) || 0
|
||||||
|
const srcH = (image && (image.naturalHeight || image.height)) || 0
|
||||||
|
if (!image || !srcW || !srcH) {
|
||||||
throw new Error('image not ready for sampling')
|
throw new Error('image not ready for sampling')
|
||||||
}
|
}
|
||||||
const srcW = image.width
|
|
||||||
const srcH = image.height
|
|
||||||
let w
|
let w
|
||||||
let h
|
let h
|
||||||
if (forceSampleSize && forceSampleSize.width && forceSampleSize.height) {
|
if (forceSampleSize && forceSampleSize.width && forceSampleSize.height) {
|
||||||
@@ -152,13 +152,20 @@ function createPointCloudSampler(scaleCanvas, viewportW, viewportH, sharedCtx) {
|
|||||||
const dg = g - bgG
|
const dg = g - bgG
|
||||||
const db = b - bgB
|
const db = b - bgB
|
||||||
const distance = Math.sqrt(dr * dr + dg * dg + db * db) / 441.67
|
const distance = Math.sqrt(dr * dr + dg * dg + db * db) / 441.67
|
||||||
const notBackground = Math.max(0, Math.min(1, (distance - 0.025) / 0.09))
|
const softKnockout = !!options.softKnockout
|
||||||
|
// 默认更狠地抠掉接近四角背景色的点;softKnockout 略放宽防采空
|
||||||
|
const distCut = softKnockout ? 0.028 : 0.05
|
||||||
|
const distRamp = softKnockout ? 0.08 : 0.07
|
||||||
|
if (distance < distCut) continue
|
||||||
|
const notBackground = Math.max(0, Math.min(1, (distance - distCut) / distRamp))
|
||||||
const chroma = Math.abs(r - g) + Math.abs(g - b) + Math.abs(r - b)
|
const chroma = Math.abs(r - g) + Math.abs(g - b) + Math.abs(r - b)
|
||||||
const nearBlack = lum < 0.07 && chroma < 16
|
const bgLum = luminance(bgR, bgG, bgB)
|
||||||
if (nearBlack) continue
|
// 仅亮底时丢弃近黑噪点,避免黑底效果图误杀主体暗部
|
||||||
const darkKeep = Math.max(0, Math.min(1, (0.62 - lum) / 0.45))
|
if (bgLum > 0.35) {
|
||||||
const softKeep = Math.max(0, Math.min(1, (distance - 0.015) / 0.07))
|
const nearBlack = lum < 0.07 && chroma < 16
|
||||||
presence = Math.max(notBackground, darkKeep * 0.5, softKeep * 0.4) * a
|
if (nearBlack) continue
|
||||||
|
}
|
||||||
|
presence = notBackground * a
|
||||||
subjectWeight = 1
|
subjectWeight = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,11 +235,41 @@ function createRenderer(canvas, cssW, cssH, dpr, scaleCanvas) {
|
|||||||
gl.clear(gl.COLOR_BUFFER_BIT)
|
gl.clear(gl.COLOR_BUFFER_BIT)
|
||||||
|
|
||||||
const pointOpacity = u.opacity != null ? u.opacity : 1
|
const pointOpacity = u.opacity != null ? u.opacity : 1
|
||||||
|
const reveal = u.reveal != null ? u.reveal : 0
|
||||||
|
|
||||||
// 完整效果图改由页面层按预览框位展示,避免 WebGL 全屏图与预览错位
|
gl.enable(gl.BLEND)
|
||||||
if (pointOpacity > 0.001 && cloudA && cloudB) {
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||||
gl.enable(gl.BLEND)
|
|
||||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
// 揭示阶段绘制效果图纹理,避免点云淡出后整页黑屏
|
||||||
|
if (hasResult && reveal > 0.001 && resultTex) {
|
||||||
|
const fit = containFit(contentAspect || 1, cssW, cssH, 0.98)
|
||||||
|
gl.useProgram(quadProgram)
|
||||||
|
gl.activeTexture(gl.TEXTURE0)
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, resultTex)
|
||||||
|
gl.uniform1i(quadUni.uTexture, 0)
|
||||||
|
gl.uniform1f(quadUni.uOpacity, Math.min(1, reveal))
|
||||||
|
gl.uniform2f(quadUni.uContentScale, fit.scaleX, fit.scaleY)
|
||||||
|
gl.uniform2f(quadUni.uContentOffset, fit.offsetX, fit.offsetY)
|
||||||
|
gl.uniform4f(
|
||||||
|
quadUni.uBgColor,
|
||||||
|
VISUAL.STAGE_BG[0],
|
||||||
|
VISUAL.STAGE_BG[1],
|
||||||
|
VISUAL.STAGE_BG[2],
|
||||||
|
1
|
||||||
|
)
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer)
|
||||||
|
if (quadAttr.aPosition >= 0) {
|
||||||
|
gl.enableVertexAttribArray(quadAttr.aPosition)
|
||||||
|
gl.vertexAttribPointer(quadAttr.aPosition, 2, gl.FLOAT, false, 16, 0)
|
||||||
|
}
|
||||||
|
if (quadAttr.aUv >= 0) {
|
||||||
|
gl.enableVertexAttribArray(quadAttr.aUv)
|
||||||
|
gl.vertexAttribPointer(quadAttr.aUv, 2, gl.FLOAT, false, 16, 8)
|
||||||
|
}
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pointOpacity > 0.001 && cloudA && cloudB && cloudA.buffer && cloudB.buffer) {
|
||||||
gl.useProgram(pointProgram)
|
gl.useProgram(pointProgram)
|
||||||
|
|
||||||
bindCloudAttribs(
|
bindCloudAttribs(
|
||||||
|
|||||||
@@ -14,23 +14,27 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
|
|||||||
let maxTexSize = VISUAL.MAX_TEX_SIZE
|
let maxTexSize = VISUAL.MAX_TEX_SIZE
|
||||||
let maxPoints = POINT_CLOUD.MEDIUM
|
let maxPoints = POINT_CLOUD.MEDIUM
|
||||||
let sampleSide = POINT_CLOUD.SAMPLE_MEDIUM
|
let sampleSide = POINT_CLOUD.SAMPLE_MEDIUM
|
||||||
let scaleCtx = null
|
|
||||||
|
|
||||||
if (scaleCanvas) {
|
/**
|
||||||
|
* 真机关键:不要用 DOM 里被 CSS 锁成 1×1 的 canvas 做 getImageData。
|
||||||
|
* 部分 WebView 会把绘图缓冲钳到 CSS 尺寸,导致效果图采点为空 → 整页黑屏。
|
||||||
|
*/
|
||||||
|
const sampleCanvas = document.createElement('canvas')
|
||||||
|
let scaleCtx = null
|
||||||
|
try {
|
||||||
|
scaleCtx = sampleCanvas.getContext('2d', { willReadFrequently: true })
|
||||||
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
// getImageData 频繁读回:声明 willReadFrequently 消除告警并提升性能
|
scaleCtx = sampleCanvas.getContext('2d')
|
||||||
scaleCtx = scaleCanvas.getContext('2d', { willReadFrequently: true })
|
} catch (e2) {
|
||||||
} catch (e) {
|
scaleCtx = null
|
||||||
try {
|
|
||||||
scaleCtx = scaleCanvas.getContext('2d')
|
|
||||||
} catch (e2) {
|
|
||||||
scaleCtx = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 保留入参 scaleCanvas 作兼容,实际采样/缩放一律走离屏 canvas
|
||||||
|
void scaleCanvas
|
||||||
|
|
||||||
const sampler = createPointCloudSampler(
|
const sampler = createPointCloudSampler(
|
||||||
scaleCanvas,
|
sampleCanvas,
|
||||||
viewportW,
|
viewportW,
|
||||||
viewportH,
|
viewportH,
|
||||||
scaleCtx
|
scaleCtx
|
||||||
@@ -114,11 +118,20 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeImageSrc(src) {
|
||||||
|
let s = String(src || '').trim()
|
||||||
|
if (!s) return ''
|
||||||
|
if (s.indexOf('//') === 0) s = 'https:' + s
|
||||||
|
if (/^http:\/\//i.test(s)) s = 'https://' + s.slice(7)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载图片并严格在 onload 之后再交给后续逻辑。
|
* 加载图片并严格在 onload 之后再交给后续逻辑。
|
||||||
* http(s) 优先 fetch→blob,避免手机 WebView 跨域导致 getImageData 采点失败。
|
* http(s) 优先 fetch→blob,避免手机 WebView 跨域导致 getImageData 采点失败。
|
||||||
*/
|
*/
|
||||||
function loadImage(src) {
|
function loadImage(rawSrc) {
|
||||||
|
const src = normalizeImageSrc(rawSrc)
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
if (!src) {
|
if (!src) {
|
||||||
reject(new Error('empty src'))
|
reject(new Error('empty src'))
|
||||||
@@ -136,8 +149,8 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
|
|||||||
if (settled) return
|
if (settled) return
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
if (settled) return
|
if (settled) return
|
||||||
const w = img.width || 0
|
const w = img.naturalWidth || img.width || 0
|
||||||
const h = img.height || 0
|
const h = img.naturalHeight || img.height || 0
|
||||||
if (!w || !h) {
|
if (!w || !h) {
|
||||||
settled = true
|
settled = true
|
||||||
if (revokeUrl) {
|
if (revokeUrl) {
|
||||||
@@ -173,15 +186,36 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
|
|||||||
|
|
||||||
// 远程图:先 blob 同源化,再采样(手机端效果图点云依赖此路径)
|
// 远程图:先 blob 同源化,再采样(手机端效果图点云依赖此路径)
|
||||||
if (/^https?:\/\//i.test(src) && typeof fetch === 'function') {
|
if (/^https?:\/\//i.test(src) && typeof fetch === 'function') {
|
||||||
fetch(src, { mode: 'cors', credentials: 'omit', cache: 'force-cache' })
|
fetch(src, { mode: 'cors', credentials: 'omit', cache: 'no-cache' })
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (!res || !res.ok) throw new Error('fetch image http ' + (res && res.status))
|
if (!res || !res.ok) throw new Error('fetch image http ' + (res && res.status))
|
||||||
return res.blob()
|
return res.blob()
|
||||||
})
|
})
|
||||||
.then(function (blob) {
|
.then(function (blob) {
|
||||||
if (!blob || !blob.size) throw new Error('empty image blob')
|
if (!blob || !blob.size) throw new Error('empty image blob')
|
||||||
|
// createImageBitmap 在部分 WebView 上更稳,再回退 blob URL
|
||||||
|
if (typeof createImageBitmap === 'function') {
|
||||||
|
return createImageBitmap(blob).then(function (bitmap) {
|
||||||
|
if (bitmap && bitmap.width && bitmap.height) {
|
||||||
|
resolve({
|
||||||
|
image: bitmap,
|
||||||
|
path: src,
|
||||||
|
width: bitmap.width,
|
||||||
|
height: bitmap.height,
|
||||||
|
revokeUrl: '',
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
throw new Error('empty bitmap')
|
||||||
|
}).catch(function () {
|
||||||
|
const objUrl = URL.createObjectURL(blob)
|
||||||
|
bindImage(objUrl, objUrl)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
const objUrl = URL.createObjectURL(blob)
|
const objUrl = URL.createObjectURL(blob)
|
||||||
bindImage(objUrl, objUrl)
|
bindImage(objUrl, objUrl)
|
||||||
|
return null
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
// CORS 拉流失败时退回 <img crossOrigin>
|
// CORS 拉流失败时退回 <img crossOrigin>
|
||||||
@@ -195,11 +229,11 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function uploadTexture(image, path) {
|
function uploadTexture(image, path) {
|
||||||
if (!image || !image.width || !image.height) {
|
if (!image || !(image.width || image.naturalWidth) || !(image.height || image.naturalHeight)) {
|
||||||
throw new Error('uploadTexture: image not ready')
|
throw new Error('uploadTexture: image not ready')
|
||||||
}
|
}
|
||||||
const srcW = image.width || 1
|
const srcW = image.naturalWidth || image.width || 1
|
||||||
const srcH = image.height || 1
|
const srcH = image.naturalHeight || image.height || 1
|
||||||
let drawW = srcW
|
let drawW = srcW
|
||||||
let drawH = srcH
|
let drawH = srcH
|
||||||
const longest = Math.max(srcW, srcH)
|
const longest = Math.max(srcW, srcH)
|
||||||
@@ -212,9 +246,9 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
|
|||||||
const tex = createEmptyTexture()
|
const tex = createEmptyTexture()
|
||||||
gl.bindTexture(gl.TEXTURE_2D, tex)
|
gl.bindTexture(gl.TEXTURE_2D, tex)
|
||||||
try {
|
try {
|
||||||
if ((drawW !== srcW || drawH !== srcH) && scaleCanvas && scaleCtx) {
|
if ((drawW !== srcW || drawH !== srcH) && sampleCanvas && scaleCtx) {
|
||||||
scaleCanvas.width = drawW
|
sampleCanvas.width = drawW
|
||||||
scaleCanvas.height = drawH
|
sampleCanvas.height = drawH
|
||||||
scaleCtx.clearRect(0, 0, drawW, drawH)
|
scaleCtx.clearRect(0, 0, drawW, drawH)
|
||||||
scaleCtx.drawImage(image, 0, 0, drawW, drawH)
|
scaleCtx.drawImage(image, 0, 0, drawW, drawH)
|
||||||
gl.texImage2D(
|
gl.texImage2D(
|
||||||
@@ -223,7 +257,7 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
|
|||||||
gl.RGBA,
|
gl.RGBA,
|
||||||
gl.RGBA,
|
gl.RGBA,
|
||||||
gl.UNSIGNED_BYTE,
|
gl.UNSIGNED_BYTE,
|
||||||
scaleCanvas
|
sampleCanvas
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
|
||||||
@@ -331,11 +365,23 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
|
|||||||
entry.height = uploaded.height
|
entry.height = uploaded.height
|
||||||
entry.aspect = uploaded.aspect
|
entry.aspect = uploaded.aspect
|
||||||
|
|
||||||
const cloud = sampler.sample(entry, {
|
let cloud = sampler.sample(entry, {
|
||||||
maxPoints: maxPoints,
|
maxPoints: maxPoints,
|
||||||
maxSide: sampleSide,
|
maxSide: sampleSide,
|
||||||
removeBackground: removeBackground,
|
removeBackground: removeBackground,
|
||||||
})
|
})
|
||||||
|
// 采点过稀:降分辨率再采,仍保持去背景(效果图不要背景点云)
|
||||||
|
if (!cloud || cloud.count < Math.max(40, maxPoints * 0.01)) {
|
||||||
|
cloud = sampler.sample(entry, {
|
||||||
|
maxPoints: maxPoints,
|
||||||
|
maxSide: Math.min(sampleSide, POINT_CLOUD.SAMPLE_LOW),
|
||||||
|
removeBackground: removeBackground,
|
||||||
|
softKnockout: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!cloud || cloud.count < Math.max(80, Math.floor(maxPoints * 0.02))) {
|
||||||
|
throw new Error('point cloud sample too sparse: ' + (cloud && cloud.count))
|
||||||
|
}
|
||||||
entry.pointCloud = cloud
|
entry.pointCloud = cloud
|
||||||
entry.pointCloudBuffer = uploadCloudBuffer(cloud)
|
entry.pointCloudBuffer = uploadCloudBuffer(cloud)
|
||||||
entry.preloadStatus = 'ready'
|
entry.preloadStatus = 'ready'
|
||||||
|
|||||||
Reference in new issue
Block a user