diff --git a/src/api/douyin.ts b/src/api/douyin.ts index ce9b5f9..94be850 100644 --- a/src/api/douyin.ts +++ b/src/api/douyin.ts @@ -4,7 +4,11 @@ const PREFIX = 'douyin' function normalizeOssUrl(url: string) { 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) { diff --git a/src/views/douyin/components/ImagePointCloud.vue b/src/views/douyin/components/ImagePointCloud.vue index ed59c3b..528f091 100644 --- a/src/views/douyin/components/ImagePointCloud.vue +++ b/src/views/douyin/components/ImagePointCloud.vue @@ -186,9 +186,9 @@ function preloadResult(url: string) { if (!url || !renderer || !sm) return resultKey = url sm.setResultPreloadStatus('loading') - // 效果图保留完整画面做点云(去背景会在手机上采空,表现为「没有效果图点云」) + // 效果图点云去掉背景,只保留主体 renderer.textures - .load(url, { removeBackground: false }) + .load(url, { removeBackground: true }) .then((entry: any) => { if (destroyed || !sm) return if (!entry || !entry.pointCloudBuffer) { @@ -428,14 +428,16 @@ onUnmounted(() => { opacity: 0; } +/* 仅占位;真实采样已改用离屏 canvas,勿再用 CSS 锁尺寸 */ .ipc-scale { position: absolute !important; - width: 1px !important; - height: 1px !important; + left: -9999px !important; + top: 0 !important; + width: 2px; + height: 2px; opacity: 0; pointer-events: none; - left: -9999px !important; - top: auto !important; + visibility: hidden; } .ipc-fallback { diff --git a/src/views/douyin/components/image-point-cloud/point-cloud-sampler.js b/src/views/douyin/components/image-point-cloud/point-cloud-sampler.js index 9a31997..0a7a87f 100644 --- a/src/views/douyin/components/image-point-cloud/point-cloud-sampler.js +++ b/src/views/douyin/components/image-point-cloud/point-cloud-sampler.js @@ -46,11 +46,11 @@ function createPointCloudSampler(scaleCanvas, viewportW, viewportH, sharedCtx) { const forceSampleSize = options.forceSampleSize || null 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') } - const srcW = image.width - const srcH = image.height let w let h if (forceSampleSize && forceSampleSize.width && forceSampleSize.height) { @@ -152,13 +152,20 @@ function createPointCloudSampler(scaleCanvas, viewportW, viewportH, sharedCtx) { const dg = g - bgG const db = b - bgB 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 nearBlack = lum < 0.07 && chroma < 16 - if (nearBlack) continue - const darkKeep = Math.max(0, Math.min(1, (0.62 - lum) / 0.45)) - const softKeep = Math.max(0, Math.min(1, (distance - 0.015) / 0.07)) - presence = Math.max(notBackground, darkKeep * 0.5, softKeep * 0.4) * a + const bgLum = luminance(bgR, bgG, bgB) + // 仅亮底时丢弃近黑噪点,避免黑底效果图误杀主体暗部 + if (bgLum > 0.35) { + const nearBlack = lum < 0.07 && chroma < 16 + if (nearBlack) continue + } + presence = notBackground * a subjectWeight = 1 } diff --git a/src/views/douyin/components/image-point-cloud/webgl/renderer.js b/src/views/douyin/components/image-point-cloud/webgl/renderer.js index 5af4ed5..95fe1b7 100644 --- a/src/views/douyin/components/image-point-cloud/webgl/renderer.js +++ b/src/views/douyin/components/image-point-cloud/webgl/renderer.js @@ -235,11 +235,41 @@ function createRenderer(canvas, cssW, cssH, dpr, scaleCanvas) { gl.clear(gl.COLOR_BUFFER_BIT) const pointOpacity = u.opacity != null ? u.opacity : 1 + const reveal = u.reveal != null ? u.reveal : 0 - // 完整效果图改由页面层按预览框位展示,避免 WebGL 全屏图与预览错位 - if (pointOpacity > 0.001 && cloudA && cloudB) { - gl.enable(gl.BLEND) - 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) bindCloudAttribs( diff --git a/src/views/douyin/components/image-point-cloud/webgl/texture-manager.js b/src/views/douyin/components/image-point-cloud/webgl/texture-manager.js index 00c702e..4ba791c 100644 --- a/src/views/douyin/components/image-point-cloud/webgl/texture-manager.js +++ b/src/views/douyin/components/image-point-cloud/webgl/texture-manager.js @@ -14,23 +14,27 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) { let maxTexSize = VISUAL.MAX_TEX_SIZE let maxPoints = POINT_CLOUD.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 { - // getImageData 频繁读回:声明 willReadFrequently 消除告警并提升性能 - scaleCtx = scaleCanvas.getContext('2d', { willReadFrequently: true }) - } catch (e) { - try { - scaleCtx = scaleCanvas.getContext('2d') - } catch (e2) { - scaleCtx = null - } + scaleCtx = sampleCanvas.getContext('2d') + } catch (e2) { + scaleCtx = null } } + // 保留入参 scaleCanvas 作兼容,实际采样/缩放一律走离屏 canvas + void scaleCanvas const sampler = createPointCloudSampler( - scaleCanvas, + sampleCanvas, viewportW, viewportH, 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 之后再交给后续逻辑。 * http(s) 优先 fetch→blob,避免手机 WebView 跨域导致 getImageData 采点失败。 */ - function loadImage(src) { + function loadImage(rawSrc) { + const src = normalizeImageSrc(rawSrc) return new Promise(function (resolve, reject) { if (!src) { reject(new Error('empty src')) @@ -136,8 +149,8 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) { if (settled) return setTimeout(function () { if (settled) return - const w = img.width || 0 - const h = img.height || 0 + const w = img.naturalWidth || img.width || 0 + const h = img.naturalHeight || img.height || 0 if (!w || !h) { settled = true if (revokeUrl) { @@ -173,15 +186,36 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) { // 远程图:先 blob 同源化,再采样(手机端效果图点云依赖此路径) 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) { if (!res || !res.ok) throw new Error('fetch image http ' + (res && res.status)) return res.blob() }) .then(function (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) bindImage(objUrl, objUrl) + return null }) .catch(function () { // CORS 拉流失败时退回 @@ -195,11 +229,11 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) { } 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') } - const srcW = image.width || 1 - const srcH = image.height || 1 + const srcW = image.naturalWidth || image.width || 1 + const srcH = image.naturalHeight || image.height || 1 let drawW = srcW let drawH = srcH const longest = Math.max(srcW, srcH) @@ -212,9 +246,9 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) { const tex = createEmptyTexture() gl.bindTexture(gl.TEXTURE_2D, tex) try { - if ((drawW !== srcW || drawH !== srcH) && scaleCanvas && scaleCtx) { - scaleCanvas.width = drawW - scaleCanvas.height = drawH + if ((drawW !== srcW || drawH !== srcH) && sampleCanvas && scaleCtx) { + sampleCanvas.width = drawW + sampleCanvas.height = drawH scaleCtx.clearRect(0, 0, drawW, drawH) scaleCtx.drawImage(image, 0, 0, drawW, drawH) gl.texImage2D( @@ -223,7 +257,7 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) { gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, - scaleCanvas + sampleCanvas ) } else { 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.aspect = uploaded.aspect - const cloud = sampler.sample(entry, { + let cloud = sampler.sample(entry, { maxPoints: maxPoints, maxSide: sampleSide, 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.pointCloudBuffer = uploadCloudBuffer(cloud) entry.preloadStatus = 'ready'