更新
This commit is contained in:
@@ -94,7 +94,9 @@
|
|||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div>
|
</div>
|
||||||
<div class="beauty-item">
|
<div class="beauty-item">
|
||||||
<span class="beauty-label">磨皮</span>
|
<el-tooltip content="磨皮仅作用于人脸皮肤,眉眼/背景/衣物不受影响" placement="left">
|
||||||
|
<span class="beauty-label has-tip">磨皮</span>
|
||||||
|
</el-tooltip>
|
||||||
<el-slider
|
<el-slider
|
||||||
v-model="settings.beautyLevel"
|
v-model="settings.beautyLevel"
|
||||||
:min="0"
|
:min="0"
|
||||||
@@ -123,6 +125,66 @@
|
|||||||
:disabled="!settings.enabled"
|
:disabled="!settings.enabled"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="beauty-item">
|
||||||
|
<el-tooltip content="淡化脸上色斑 / 黑色素沉着,仅作用于人脸皮肤" placement="left">
|
||||||
|
<span class="beauty-label has-tip">祛斑</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-slider
|
||||||
|
v-model="settings.spotLevel"
|
||||||
|
:min="0"
|
||||||
|
:max="BEAUTY_LEVEL_MAX"
|
||||||
|
:step="1"
|
||||||
|
:disabled="!settings.enabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="beauty-item">
|
||||||
|
<el-tooltip content="淡化脸上小面积深色痣点,仅作用于人脸皮肤" placement="left">
|
||||||
|
<span class="beauty-label has-tip">去痣</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-slider
|
||||||
|
v-model="settings.moleLevel"
|
||||||
|
:min="0"
|
||||||
|
:max="BEAUTY_LEVEL_MAX"
|
||||||
|
:step="1"
|
||||||
|
:disabled="!settings.enabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="beauty-item">
|
||||||
|
<el-tooltip content="提亮眼下区域,减轻黑眼圈" placement="left">
|
||||||
|
<span class="beauty-label has-tip">去黑眼圈</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-slider
|
||||||
|
v-model="settings.darkCircleLevel"
|
||||||
|
:min="0"
|
||||||
|
:max="BEAUTY_LEVEL_MAX"
|
||||||
|
:step="1"
|
||||||
|
:disabled="!settings.enabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="beauty-item">
|
||||||
|
<el-tooltip content="提升画面整体亮度,适合光线偏暗环境" placement="left">
|
||||||
|
<span class="beauty-label has-tip">亮度</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-slider
|
||||||
|
v-model="settings.brightnessLevel"
|
||||||
|
:min="0"
|
||||||
|
:max="BEAUTY_LEVEL_MAX"
|
||||||
|
:step="1"
|
||||||
|
:disabled="!settings.enabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="beauty-item">
|
||||||
|
<el-tooltip content="锐化细节,让画面更清晰" placement="left">
|
||||||
|
<span class="beauty-label has-tip">清晰度</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-slider
|
||||||
|
v-model="settings.clarityLevel"
|
||||||
|
:min="0"
|
||||||
|
:max="BEAUTY_LEVEL_MAX"
|
||||||
|
:step="1"
|
||||||
|
:disabled="!settings.enabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-divider class="beauty-divider" />
|
<el-divider class="beauty-divider" />
|
||||||
@@ -261,7 +323,8 @@ installBeautyMediaInterceptor()
|
|||||||
const settings = reactive(loadBeautySettings())
|
const settings = reactive(loadBeautySettings())
|
||||||
const advancedSupported = isAdvancedBeautySupported()
|
const advancedSupported = isAdvancedBeautySupported()
|
||||||
|
|
||||||
if (settings.advanced.enabled) preloadBeautyModel()
|
// 面板挂载即预热模型,避免首次打开预览时同步加载 WASM/模型卡死
|
||||||
|
if (advancedSupported) preloadBeautyModel()
|
||||||
|
|
||||||
/** 美颜已开启但当前没有活跃通话管线 → 摄像头是在开启前用原始画面打开的 */
|
/** 美颜已开启但当前没有活跃通话管线 → 摄像头是在开启前用原始画面打开的 */
|
||||||
const needCameraRestartHint = computed(() => {
|
const needCameraRestartHint = computed(() => {
|
||||||
@@ -304,13 +367,18 @@ const onDragStart = (e: MouseEvent) => {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
const openPanel = () => {
|
const openPanel = async () => {
|
||||||
if (visible.value) return
|
if (visible.value) return
|
||||||
windowPos.x = Math.max(16, Math.floor((window.innerWidth - WINDOW_WIDTH) / 2))
|
windowPos.x = Math.max(16, Math.floor((window.innerWidth - WINDOW_WIDTH) / 2))
|
||||||
windowPos.y = Math.max(16, Math.floor(window.innerHeight * 0.12))
|
windowPos.y = Math.max(16, Math.floor(window.innerHeight * 0.12))
|
||||||
visible.value = true
|
visible.value = true
|
||||||
// 打开面板即自动开启预览,省一次点击
|
// 先预热模型,并等面板先画出「正在打开摄像头」再启预览,避免首开卡死
|
||||||
if (advancedSupported) void startPreview()
|
if (advancedSupported) {
|
||||||
|
preloadBeautyModel()
|
||||||
|
await nextTick()
|
||||||
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||||
|
void startPreview()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const closePanel = () => {
|
const closePanel = () => {
|
||||||
@@ -325,11 +393,15 @@ let applyTimer: ReturnType<typeof setTimeout> | null = null
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 基础美颜下发到 TUICallEngine(TRTC BasicBeauty 插件)。
|
* 基础美颜下发到 TUICallEngine(TRTC BasicBeauty 插件)。
|
||||||
* 仅作为 WebGL 不可用时的回退——正常情况下磨皮/美白/红润统一走本地管线,
|
* 仅作为 WebGL 不可用时的回退——正常情况下全部效果走本地管线。
|
||||||
* 若再叠加引擎插件会造成双重美颜。
|
* WebGL 可用时始终向引擎下发全 0,确保引擎侧插件保持关闭,
|
||||||
|
* 不会与本地管线叠加,也保证「关闭美颜」时引擎侧残留效果被清掉。
|
||||||
*/
|
*/
|
||||||
const applyNow = async () => {
|
const applyNow = async () => {
|
||||||
if (advancedSupported) return
|
if (advancedSupported) {
|
||||||
|
await applyBeautyToEngine(props.getEngine(), { ...settings, enabled: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
await applyBeautyToEngine(props.getEngine(), settings)
|
await applyBeautyToEngine(props.getEngine(), settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,14 +451,22 @@ const startPreview = async () => {
|
|||||||
previewError.value = ''
|
previewError.value = ''
|
||||||
previewLoading.value = true
|
previewLoading.value = true
|
||||||
try {
|
try {
|
||||||
// 走原始 getUserMedia,避免被拦截器再处理一遍
|
// 720p 采集保证预览清晰;管线内部会做适度降采样控制处理开销
|
||||||
const raw = await getRawUserMedia({
|
const raw = await getRawUserMedia({
|
||||||
video: { width: { ideal: 1280 }, height: { ideal: 720 } },
|
video: {
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 },
|
||||||
|
frameRate: { ideal: 30 }
|
||||||
|
},
|
||||||
audio: false
|
audio: false
|
||||||
})
|
})
|
||||||
const track = raw.getVideoTracks()[0]
|
const track = raw.getVideoTracks()[0]
|
||||||
if (!track) throw new Error('未获取到摄像头画面')
|
if (!track) throw new Error('未获取到摄像头画面')
|
||||||
|
|
||||||
|
// 让出一帧,保证 loading 文案先渲染
|
||||||
|
await nextTick()
|
||||||
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||||
|
|
||||||
previewPipeline = new FaceBeautyPipeline(
|
previewPipeline = new FaceBeautyPipeline(
|
||||||
track,
|
track,
|
||||||
{ ...settings, advanced: { ...settings.advanced } },
|
{ ...settings, advanced: { ...settings.advanced } },
|
||||||
@@ -443,7 +523,12 @@ const handleReset = () => {
|
|||||||
style: defaults.style,
|
style: defaults.style,
|
||||||
beautyLevel: defaults.beautyLevel,
|
beautyLevel: defaults.beautyLevel,
|
||||||
whitenessLevel: defaults.whitenessLevel,
|
whitenessLevel: defaults.whitenessLevel,
|
||||||
ruddinessLevel: defaults.ruddinessLevel
|
ruddinessLevel: defaults.ruddinessLevel,
|
||||||
|
spotLevel: defaults.spotLevel,
|
||||||
|
moleLevel: defaults.moleLevel,
|
||||||
|
darkCircleLevel: defaults.darkCircleLevel,
|
||||||
|
brightnessLevel: defaults.brightnessLevel,
|
||||||
|
clarityLevel: defaults.clarityLevel
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +560,8 @@ defineExpose({
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.beauty-window {
|
.beauty-window {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
max-width: 94vw;
|
max-width: calc(100vw - 24px);
|
||||||
|
max-height: calc(100vh - 24px);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.28);
|
||||||
@@ -514,9 +600,13 @@ defineExpose({
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,12 +619,13 @@ defineExpose({
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #111;
|
background: #111;
|
||||||
aspect-ratio: 4 / 3;
|
aspect-ratio: 16 / 9;
|
||||||
|
|
||||||
.beauty-preview-video {
|
.beauty-preview-video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
/* contain 完整显示画面,与实际发送给对方的内容一致,不裁切 */
|
||||||
|
object-fit: contain;
|
||||||
/* 本地自拍习惯镜像显示;实际发送给对方的画面不镜像 */
|
/* 本地自拍习惯镜像显示;实际发送给对方的画面不镜像 */
|
||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
}
|
}
|
||||||
@@ -606,10 +697,14 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.beauty-control-col {
|
.beauty-control-col {
|
||||||
flex: 0 0 280px;
|
flex: 0 0 300px;
|
||||||
min-width: 260px;
|
min-width: 260px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 6px;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.beauty-section-header {
|
.beauty-section-header {
|
||||||
@@ -651,9 +746,15 @@ defineExpose({
|
|||||||
|
|
||||||
.beauty-label {
|
.beauty-label {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 52px;
|
width: 64px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #606266;
|
color: #606266;
|
||||||
|
|
||||||
|
&.has-tip {
|
||||||
|
cursor: help;
|
||||||
|
text-decoration: underline dotted #c0c4cc;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-slider {
|
.el-slider {
|
||||||
@@ -664,6 +765,17 @@ defineExpose({
|
|||||||
.el-color-picker {
|
.el-color-picker {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.beauty-item-hint {
|
||||||
|
margin-top: -2px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
|
||||||
|
.beauty-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* 本地美颜管线(磨皮/美白/红润/瘦脸/口红/腮红)
|
* 本地美颜管线(磨皮/美白/红润/祛斑/去痣/去黑眼圈/亮度/清晰度/瘦脸/口红/腮红)
|
||||||
*
|
*
|
||||||
* 实现思路(全部免费、不依赖腾讯特效 SDK):
|
* 实现思路(全部免费、不依赖腾讯特效 SDK):
|
||||||
* 1. MediaPipe Face Landmarker(Apache 2.0)在浏览器内逐帧输出 478 个人脸关键点;
|
* 1. MediaPipe Face Landmarker(Apache 2.0)在浏览器内逐帧输出 478 个人脸关键点;
|
||||||
* 2. WebGL 多 pass 渲染:
|
* 2. WebGL 多 pass 渲染:
|
||||||
* - 瘦脸:片元着色器局部液化变形,把下颌关键点附近的像素向面部中心推;
|
* - 瘦脸:片元着色器局部液化变形,把下颌关键点附近的像素向面部中心推;
|
||||||
* - 磨皮:分离高斯模糊 + 亮度差边缘保护混合(保留五官细节,只柔化皮肤);
|
* - 磨皮:分离高斯模糊 + 亮度差边缘保护混合,并用 MediaPipe 人脸椭圆蒙版
|
||||||
* - 美白/红润:中间调提亮曲线 + 暖色偏移;
|
* 限制只作用于人脸区域(背景/衣物/桌面等不磨皮);
|
||||||
|
* - 美白/红润:中间调提亮曲线 + 暖色偏移(同样受人脸蒙版约束);
|
||||||
|
* - 祛斑:人脸内暗斑相对模糊肤色的偏差压回,淡化黑色素/色斑;
|
||||||
|
* - 去痣:人脸内小面积深色点更强力向周围肤色融合;
|
||||||
|
* - 去黑眼圈:眼下区域局部提亮并与周围肤色混合;
|
||||||
|
* - 亮度:整帧提亮(不依赖人脸);
|
||||||
|
* - 清晰度:unsharp mask 锐化细节(整帧);
|
||||||
* 3. 口红/腮红:按唇部/颧骨关键点在 2D Canvas 上着色,用混合模式叠回画面;
|
* 3. 口红/腮红:按唇部/颧骨关键点在 2D Canvas 上着色,用混合模式叠回画面;
|
||||||
* 4. 处理结果经 canvas.captureStream() 输出为新的视频轨,由 getUserMedia
|
* 4. 处理结果经 canvas.captureStream() 输出为新的视频轨,由 getUserMedia
|
||||||
* 拦截器(beauty-media-interceptor.ts)透明地替换给 TUICallKit。
|
* 拦截器(beauty-media-interceptor.ts)透明地替换给 TUICallKit。
|
||||||
*
|
*
|
||||||
* 注:基础美颜(磨皮/美白/红润)不再走 TRTC 引擎的 BasicBeauty 插件,统一在
|
* 注:基础美颜(磨皮/美白/红润/祛斑/去痣/去黑眼圈/亮度/清晰度)不再走 TRTC 引擎的 BasicBeauty 插件,统一在
|
||||||
* 本管线内处理,保证预览所见即所得;仅在 WebGL 不可用时回退引擎插件。
|
* 本管线内处理,保证预览所见即所得;仅在 WebGL 不可用时回退引擎插件。
|
||||||
*
|
*
|
||||||
* 模型与 WASM 优先从本站 /mediapipe/ 加载(随前端部署),失败时回退官方 CDN。
|
* 模型与 WASM 优先从本站 /mediapipe/ 加载(随前端部署),失败时回退官方 CDN。
|
||||||
@@ -86,6 +92,9 @@ const LIPS_INNER = [
|
|||||||
/** 左/右颧骨中心(腮红位置) */
|
/** 左/右颧骨中心(腮红位置) */
|
||||||
const CHEEK_LEFT = 50
|
const CHEEK_LEFT = 50
|
||||||
const CHEEK_RIGHT = 280
|
const CHEEK_RIGHT = 280
|
||||||
|
/** 去黑眼圈:左右眼下区域参考点(下眼睑中点附近) */
|
||||||
|
const UNDER_EYE_LEFT = 111
|
||||||
|
const UNDER_EYE_RIGHT = 340
|
||||||
/** 面部参考点 */
|
/** 面部参考点 */
|
||||||
const NOSE_TIP = 4
|
const NOSE_TIP = 4
|
||||||
const CHIN = 152
|
const CHIN = 152
|
||||||
@@ -94,6 +103,14 @@ const FACE_RIGHT = 454
|
|||||||
/** 瘦脸拖拽点:左右各 3 个下颌/脸颊关键点 */
|
/** 瘦脸拖拽点:左右各 3 个下颌/脸颊关键点 */
|
||||||
const JAW_LEFT = [132, 172, 136]
|
const JAW_LEFT = [132, 172, 136]
|
||||||
const JAW_RIGHT = [361, 397, 365]
|
const JAW_RIGHT = [361, 397, 365]
|
||||||
|
/**
|
||||||
|
* MediaPipe Face Mesh 人脸外轮廓(Face Oval),用于磨皮/美白蒙版。
|
||||||
|
* 顺序沿轮廓闭环,覆盖额头到下颌,不含耳朵与肩颈。
|
||||||
|
*/
|
||||||
|
const FACE_OVAL = [
|
||||||
|
10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288, 397, 365, 379, 378, 400, 377, 152,
|
||||||
|
148, 176, 149, 150, 136, 172, 58, 132, 93, 234, 127, 162, 21, 54, 103, 67, 109
|
||||||
|
]
|
||||||
|
|
||||||
const MAX_WARPS = 6
|
const MAX_WARPS = 6
|
||||||
|
|
||||||
@@ -159,22 +176,97 @@ const FINAL_FRAGMENT = `
|
|||||||
precision mediump float;
|
precision mediump float;
|
||||||
uniform sampler2D u_sharp;
|
uniform sampler2D u_sharp;
|
||||||
uniform sampler2D u_blur;
|
uniform sampler2D u_blur;
|
||||||
|
uniform sampler2D u_faceMask;
|
||||||
uniform float u_smooth;
|
uniform float u_smooth;
|
||||||
uniform float u_white;
|
uniform float u_white;
|
||||||
uniform float u_ruddy;
|
uniform float u_ruddy;
|
||||||
|
uniform float u_spot;
|
||||||
|
uniform float u_mole;
|
||||||
|
uniform float u_darkCircle;
|
||||||
|
uniform float u_brightness;
|
||||||
|
uniform float u_clarity;
|
||||||
uniform float u_edgeKeep;
|
uniform float u_edgeKeep;
|
||||||
|
uniform float u_hasFaceMask;
|
||||||
|
uniform vec2 u_underEyeL;
|
||||||
|
uniform vec2 u_underEyeR;
|
||||||
|
uniform float u_underEyeRadius;
|
||||||
|
uniform vec2 u_aspect;
|
||||||
varying vec2 v_uv;
|
varying vec2 v_uv;
|
||||||
|
|
||||||
|
float underEyeWeight(vec2 uv, vec2 center, float radius) {
|
||||||
|
if (radius < 0.0001) return 0.0;
|
||||||
|
vec2 d = (uv - center) * u_aspect;
|
||||||
|
// 纵向略压扁,更贴合眼下月牙形
|
||||||
|
d.y *= 1.35;
|
||||||
|
float dist = length(d);
|
||||||
|
return 1.0 - smoothstep(radius * 0.35, radius, dist);
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
vec3 a = texture2D(u_sharp, v_uv).rgb;
|
vec3 a = texture2D(u_sharp, v_uv).rgb;
|
||||||
vec3 b = texture2D(u_blur, v_uv).rgb;
|
vec3 b = texture2D(u_blur, v_uv).rgb;
|
||||||
|
// 人脸蒙版:仅对人脸区域做基础美颜,背景保持原图
|
||||||
|
float face = u_hasFaceMask > 0.5 ? texture2D(u_faceMask, v_uv).r : 0.0;
|
||||||
// 亮度差大 = 五官/轮廓边缘,磨皮时予以保留
|
// 亮度差大 = 五官/轮廓边缘,磨皮时予以保留
|
||||||
float lumDiff = abs(dot(a - b, vec3(0.299, 0.587, 0.114)));
|
float lumA = dot(a, vec3(0.299, 0.587, 0.114));
|
||||||
float edge = smoothstep(0.05, 0.22, lumDiff) * u_edgeKeep;
|
float lumB = dot(b, vec3(0.299, 0.587, 0.114));
|
||||||
vec3 c = mix(a, b, u_smooth * (1.0 - edge));
|
float lumDiff = abs(lumA - lumB);
|
||||||
// 美白:提亮中间调,避免高光过曝
|
float edge = smoothstep(0.06, 0.25, lumDiff) * u_edgeKeep;
|
||||||
c = c + u_white * c * (vec3(1.0) - c) * 0.55;
|
float smoothAmt = u_smooth * (1.0 - edge) * face;
|
||||||
// 红润:暖色偏移
|
vec3 c = mix(a, b, smoothAmt);
|
||||||
c *= vec3(1.0) + u_ruddy * vec3(0.10, 0.0, -0.04);
|
|
||||||
|
// 祛斑 / 淡化黑色素:暗于周围肤色的区域向肤色靠拢(带阈值避免放大噪点)
|
||||||
|
if (u_spot > 0.001 && face > 0.01) {
|
||||||
|
float darker = smoothstep(0.015, 0.12, lumB - lumA);
|
||||||
|
float spotMask = darker * (1.0 - edge * 0.6) * face;
|
||||||
|
c = mix(c, b, min(1.0, spotMask * u_spot * 1.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去痣:更深色的小点更强力融合到周围肤色(阈值比祛斑高、力度更大)
|
||||||
|
if (u_mole > 0.001 && face > 0.01) {
|
||||||
|
float moleDark = smoothstep(0.05, 0.16, lumB - lumA);
|
||||||
|
c = mix(c, b, min(1.0, moleDark * face * u_mole * 1.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去黑眼圈:眼下区域提亮并与周围肤色混合
|
||||||
|
if (u_darkCircle > 0.001 && face > 0.01) {
|
||||||
|
float eyeW = max(
|
||||||
|
underEyeWeight(v_uv, u_underEyeL, u_underEyeRadius),
|
||||||
|
underEyeWeight(v_uv, u_underEyeR, u_underEyeRadius)
|
||||||
|
) * face;
|
||||||
|
if (eyeW > 0.001) {
|
||||||
|
vec3 lifted = mix(c, b, 0.6);
|
||||||
|
lifted = lifted + lifted * (vec3(1.0) - lifted) * 0.3;
|
||||||
|
c = mix(c, lifted, eyeW * u_darkCircle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 美白:gamma 曲线提亮(比线性中间调提亮更通透、不发灰),仅人脸
|
||||||
|
if (u_white > 0.001 && face > 0.01) {
|
||||||
|
vec3 whitened = pow(max(c, vec3(0.0)), vec3(1.0 - u_white * 0.45));
|
||||||
|
c = mix(c, whitened, face);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 红润:中间调加暖,避开高光/阴影防止整体偏橙,仅人脸
|
||||||
|
if (u_ruddy > 0.001 && face > 0.01) {
|
||||||
|
float lum = dot(c, vec3(0.299, 0.587, 0.114));
|
||||||
|
float mid = smoothstep(0.08, 0.3, lum) * (1.0 - smoothstep(0.7, 0.95, lum));
|
||||||
|
float r = u_ruddy * face * mid;
|
||||||
|
vec3 warm = c * vec3(1.0 + 0.13 * r, 1.0 + 0.02 * r, 1.0 - 0.05 * r);
|
||||||
|
c = mix(c, warm, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清晰度:unsharp mask,限幅防光晕;磨皮强的区域少锐化,避免互相抵消
|
||||||
|
if (u_clarity > 0.001) {
|
||||||
|
vec3 detail = clamp(a - b, vec3(-0.18), vec3(0.18));
|
||||||
|
c += detail * u_clarity * (1.0 - smoothAmt * 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 亮度:整帧提亮(不依赖人脸)
|
||||||
|
if (u_brightness > 0.001) {
|
||||||
|
c = c + u_brightness * c * (vec3(1.0) - c);
|
||||||
|
}
|
||||||
|
|
||||||
gl_FragColor = vec4(clamp(c, 0.0, 1.0), 1.0);
|
gl_FragColor = vec4(clamp(c, 0.0, 1.0), 1.0);
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
@@ -186,6 +278,16 @@ export interface BasicRenderParams {
|
|||||||
white: number
|
white: number
|
||||||
/** 红润 0-1 */
|
/** 红润 0-1 */
|
||||||
ruddy: number
|
ruddy: number
|
||||||
|
/** 祛斑 / 淡化黑色素 0-1 */
|
||||||
|
spot: number
|
||||||
|
/** 去痣 0-1 */
|
||||||
|
mole: number
|
||||||
|
/** 去黑眼圈 0-1 */
|
||||||
|
darkCircle: number
|
||||||
|
/** 亮度 0-1(整帧) */
|
||||||
|
brightness: number
|
||||||
|
/** 清晰度 / 锐化 0-1(整帧) */
|
||||||
|
clarity: number
|
||||||
/** 边缘保护系数:自然=1.0 保留更多细节,光滑=0.55 更平滑 */
|
/** 边缘保护系数:自然=1.0 保留更多细节,光滑=0.55 更平滑 */
|
||||||
edgeKeep: number
|
edgeKeep: number
|
||||||
}
|
}
|
||||||
@@ -206,6 +308,7 @@ class BeautyRenderer {
|
|||||||
readonly canvas: HTMLCanvasElement
|
readonly canvas: HTMLCanvasElement
|
||||||
private gl: WebGLRenderingContext
|
private gl: WebGLRenderingContext
|
||||||
private videoTexture: WebGLTexture
|
private videoTexture: WebGLTexture
|
||||||
|
private faceMaskTexture: WebGLTexture
|
||||||
private warpProg: ProgramInfo
|
private warpProg: ProgramInfo
|
||||||
private blurProg: ProgramInfo
|
private blurProg: ProgramInfo
|
||||||
private finalProg: ProgramInfo
|
private finalProg: ProgramInfo
|
||||||
@@ -213,6 +316,9 @@ class BeautyRenderer {
|
|||||||
private fboB: FboInfo
|
private fboB: FboInfo
|
||||||
private fboC: FboInfo
|
private fboC: FboInfo
|
||||||
private lost = false
|
private lost = false
|
||||||
|
/** 2D 画布:按人脸椭圆绘制磨皮蒙版后上传为 WebGL 纹理 */
|
||||||
|
private maskCanvas: HTMLCanvasElement
|
||||||
|
private maskCtx: CanvasRenderingContext2D
|
||||||
|
|
||||||
constructor(width: number, height: number) {
|
constructor(width: number, height: number) {
|
||||||
this.canvas = document.createElement('canvas')
|
this.canvas = document.createElement('canvas')
|
||||||
@@ -228,6 +334,13 @@ class BeautyRenderer {
|
|||||||
this.lost = true
|
this.lost = true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.maskCanvas = document.createElement('canvas')
|
||||||
|
this.maskCanvas.width = width
|
||||||
|
this.maskCanvas.height = height
|
||||||
|
const maskCtx = this.maskCanvas.getContext('2d')
|
||||||
|
if (!maskCtx) throw new Error('Canvas 2D 不可用')
|
||||||
|
this.maskCtx = maskCtx
|
||||||
|
|
||||||
const quad = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1])
|
const quad = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1])
|
||||||
const buffer = gl.createBuffer()
|
const buffer = gl.createBuffer()
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
||||||
@@ -245,13 +358,25 @@ class BeautyRenderer {
|
|||||||
this.finalProg = this.buildProgram(FINAL_FRAGMENT, [
|
this.finalProg = this.buildProgram(FINAL_FRAGMENT, [
|
||||||
'u_sharp',
|
'u_sharp',
|
||||||
'u_blur',
|
'u_blur',
|
||||||
|
'u_faceMask',
|
||||||
'u_smooth',
|
'u_smooth',
|
||||||
'u_white',
|
'u_white',
|
||||||
'u_ruddy',
|
'u_ruddy',
|
||||||
'u_edgeKeep'
|
'u_spot',
|
||||||
|
'u_mole',
|
||||||
|
'u_darkCircle',
|
||||||
|
'u_brightness',
|
||||||
|
'u_clarity',
|
||||||
|
'u_edgeKeep',
|
||||||
|
'u_hasFaceMask',
|
||||||
|
'u_underEyeL',
|
||||||
|
'u_underEyeR',
|
||||||
|
'u_underEyeRadius',
|
||||||
|
'u_aspect'
|
||||||
])
|
])
|
||||||
|
|
||||||
this.videoTexture = this.createTexture()
|
this.videoTexture = this.createTexture()
|
||||||
|
this.faceMaskTexture = this.createTexture()
|
||||||
this.fboA = this.createFbo(width, height)
|
this.fboA = this.createFbo(width, height)
|
||||||
this.fboB = this.createFbo(width, height)
|
this.fboB = this.createFbo(width, height)
|
||||||
this.fboC = this.createFbo(width, height)
|
this.fboC = this.createFbo(width, height)
|
||||||
@@ -335,9 +460,15 @@ class BeautyRenderer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param warps 每项 [centerX, centerY, offsetX, offsetY, radius](归一化 uv 坐标)
|
* @param warps 每项 [centerX, centerY, offsetX, offsetY, radius](归一化 uv 坐标)
|
||||||
* @param basic 磨皮/美白/红润参数(0-1)
|
* @param basic 磨皮/美白/红润/祛斑/去痣/去黑眼圈/亮度/清晰度参数(0-1)
|
||||||
|
* @param faceLandmarks 人脸关键点;有则只对人脸椭圆区域做肤色类美颜,无则跳过(避免背景被磨)
|
||||||
*/
|
*/
|
||||||
render(video: HTMLVideoElement, warps: number[][], basic: BasicRenderParams) {
|
render(
|
||||||
|
video: HTMLVideoElement,
|
||||||
|
warps: number[][],
|
||||||
|
basic: BasicRenderParams,
|
||||||
|
faceLandmarks: NormalizedLandmark[] | null = null
|
||||||
|
) {
|
||||||
const gl = this.gl
|
const gl = this.gl
|
||||||
const { width, height } = this.canvas
|
const { width, height } = this.canvas
|
||||||
|
|
||||||
@@ -367,35 +498,220 @@ class BeautyRenderer {
|
|||||||
gl.uniform2f(wp.uniforms.u_aspect, width / height, 1)
|
gl.uniform2f(wp.uniforms.u_aspect, width / height, 1)
|
||||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||||
|
|
||||||
// Pass 2/3: 分离高斯模糊(仅磨皮开启时),模糊半径随磨皮强度增大
|
const hasFaceMask = Boolean(faceLandmarks && faceLandmarks.length > 0)
|
||||||
let blurTex = this.fboA.tex
|
const wantFaceSkin =
|
||||||
if (basic.smooth > 0.001) {
|
basic.smooth > 0.001 ||
|
||||||
const spacing = 1 + basic.smooth * 3
|
basic.white > 0.001 ||
|
||||||
const bp = this.blurProg
|
basic.ruddy > 0.001 ||
|
||||||
this.usePass(bp, this.fboB.fb, false)
|
basic.spot > 0.001 ||
|
||||||
this.bindTex(0, this.fboA.tex, bp.uniforms.u_tex)
|
basic.mole > 0.001 ||
|
||||||
gl.uniform2f(bp.uniforms.u_dir, spacing / width, 0)
|
basic.darkCircle > 0.001
|
||||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
const wantGlobal = basic.brightness > 0.001 || basic.clarity > 0.001
|
||||||
|
const applyFaceSkin = wantFaceSkin && hasFaceMask
|
||||||
|
|
||||||
this.usePass(bp, this.fboC.fb, false)
|
// 无人脸且无整帧效果时,直接输出变形后画面
|
||||||
this.bindTex(0, this.fboB.tex, bp.uniforms.u_tex)
|
if (!applyFaceSkin && !wantGlobal) {
|
||||||
gl.uniform2f(bp.uniforms.u_dir, 0, spacing / height)
|
this.drawFinalPass(this.fboA.tex, this.fboA.tex, {
|
||||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
...basic,
|
||||||
|
smooth: 0,
|
||||||
|
white: 0,
|
||||||
|
ruddy: 0,
|
||||||
|
spot: 0,
|
||||||
|
mole: 0,
|
||||||
|
darkCircle: 0,
|
||||||
|
brightness: 0,
|
||||||
|
clarity: 0
|
||||||
|
}, false, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applyFaceSkin) {
|
||||||
|
this.uploadFaceMask(faceLandmarks!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2/3: 分离高斯模糊(磨皮/祛斑/去痣/去黑眼圈/清晰度需要)
|
||||||
|
let blurTex = this.fboA.tex
|
||||||
|
const needBlur =
|
||||||
|
(applyFaceSkin &&
|
||||||
|
(basic.smooth > 0.001 ||
|
||||||
|
basic.spot > 0.001 ||
|
||||||
|
basic.mole > 0.001 ||
|
||||||
|
basic.darkCircle > 0.001)) ||
|
||||||
|
basic.clarity > 0.001
|
||||||
|
if (needBlur) {
|
||||||
|
const blurStrength = Math.max(
|
||||||
|
applyFaceSkin ? basic.smooth : 0,
|
||||||
|
applyFaceSkin ? basic.spot * 0.9 : 0,
|
||||||
|
applyFaceSkin ? basic.mole * 0.95 : 0,
|
||||||
|
applyFaceSkin ? basic.darkCircle * 0.7 : 0,
|
||||||
|
basic.clarity > 0.001 ? 0.35 : 0
|
||||||
|
)
|
||||||
|
// 按画面尺寸自适应步长;祛斑/去痣需要更大的模糊半径才能得到干净的肤色参考
|
||||||
|
const sizeScale = Math.max(1, Math.min(width, height) / 480)
|
||||||
|
const spacing = (1.5 + blurStrength * 4) * sizeScale
|
||||||
|
// 强效果时跑两轮分离模糊,等效更大半径,斑/痣不再残留在参考色里
|
||||||
|
const iterations = blurStrength > 0.35 ? 2 : 1
|
||||||
|
const bp = this.blurProg
|
||||||
|
let srcTex = this.fboA.tex
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
this.usePass(bp, this.fboB.fb, false)
|
||||||
|
this.bindTex(0, srcTex, bp.uniforms.u_tex)
|
||||||
|
gl.uniform2f(bp.uniforms.u_dir, spacing / width, 0)
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||||
|
|
||||||
|
this.usePass(bp, this.fboC.fb, false)
|
||||||
|
this.bindTex(0, this.fboB.tex, bp.uniforms.u_tex)
|
||||||
|
gl.uniform2f(bp.uniforms.u_dir, 0, spacing / height)
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||||
|
srcTex = this.fboC.tex
|
||||||
|
}
|
||||||
blurTex = this.fboC.tex
|
blurTex = this.fboC.tex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 4: 磨皮混合 + 美白 + 红润 → 画布
|
const underEye = applyFaceSkin
|
||||||
|
? this.resolveUnderEyeParams(faceLandmarks!, width, height)
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Pass 4: 肤色类效果(受人脸蒙版)+ 亮度/清晰度(整帧)→ 画布
|
||||||
|
this.drawFinalPass(
|
||||||
|
this.fboA.tex,
|
||||||
|
blurTex,
|
||||||
|
applyFaceSkin
|
||||||
|
? basic
|
||||||
|
: {
|
||||||
|
...basic,
|
||||||
|
smooth: 0,
|
||||||
|
white: 0,
|
||||||
|
ruddy: 0,
|
||||||
|
spot: 0,
|
||||||
|
mole: 0,
|
||||||
|
darkCircle: 0
|
||||||
|
},
|
||||||
|
applyFaceSkin,
|
||||||
|
underEye
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawFinalPass(
|
||||||
|
sharpTex: WebGLTexture,
|
||||||
|
blurTex: WebGLTexture,
|
||||||
|
basic: BasicRenderParams,
|
||||||
|
hasFaceMask: boolean,
|
||||||
|
underEye: {
|
||||||
|
leftX: number
|
||||||
|
leftY: number
|
||||||
|
rightX: number
|
||||||
|
rightY: number
|
||||||
|
radius: number
|
||||||
|
} | null
|
||||||
|
) {
|
||||||
|
const gl = this.gl
|
||||||
|
const { width, height } = this.canvas
|
||||||
const fp = this.finalProg
|
const fp = this.finalProg
|
||||||
this.usePass(fp, null, true)
|
this.usePass(fp, null, true)
|
||||||
this.bindTex(0, this.fboA.tex, fp.uniforms.u_sharp)
|
this.bindTex(0, sharpTex, fp.uniforms.u_sharp)
|
||||||
this.bindTex(1, blurTex, fp.uniforms.u_blur)
|
this.bindTex(1, blurTex, fp.uniforms.u_blur)
|
||||||
|
this.bindTex(2, this.faceMaskTexture, fp.uniforms.u_faceMask)
|
||||||
gl.uniform1f(fp.uniforms.u_smooth, Math.min(0.92, basic.smooth))
|
gl.uniform1f(fp.uniforms.u_smooth, Math.min(0.92, basic.smooth))
|
||||||
gl.uniform1f(fp.uniforms.u_white, basic.white)
|
gl.uniform1f(fp.uniforms.u_white, basic.white)
|
||||||
gl.uniform1f(fp.uniforms.u_ruddy, basic.ruddy)
|
gl.uniform1f(fp.uniforms.u_ruddy, basic.ruddy)
|
||||||
|
gl.uniform1f(fp.uniforms.u_spot, Math.min(0.95, basic.spot))
|
||||||
|
gl.uniform1f(fp.uniforms.u_mole, Math.min(0.98, basic.mole))
|
||||||
|
gl.uniform1f(fp.uniforms.u_darkCircle, Math.min(0.9, basic.darkCircle))
|
||||||
|
gl.uniform1f(fp.uniforms.u_brightness, Math.min(0.55, basic.brightness))
|
||||||
|
gl.uniform1f(fp.uniforms.u_clarity, Math.min(1.2, basic.clarity))
|
||||||
gl.uniform1f(fp.uniforms.u_edgeKeep, basic.edgeKeep)
|
gl.uniform1f(fp.uniforms.u_edgeKeep, basic.edgeKeep)
|
||||||
|
gl.uniform1f(fp.uniforms.u_hasFaceMask, hasFaceMask ? 1 : 0)
|
||||||
|
gl.uniform2f(fp.uniforms.u_underEyeL, underEye?.leftX ?? 0, underEye?.leftY ?? 0)
|
||||||
|
gl.uniform2f(fp.uniforms.u_underEyeR, underEye?.rightX ?? 0, underEye?.rightY ?? 0)
|
||||||
|
gl.uniform1f(fp.uniforms.u_underEyeRadius, underEye?.radius ?? 0)
|
||||||
|
gl.uniform2f(fp.uniforms.u_aspect, width / height, 1)
|
||||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 眼下区域中心与半径(归一化 uv) */
|
||||||
|
private resolveUnderEyeParams(
|
||||||
|
lm: NormalizedLandmark[],
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): { leftX: number; leftY: number; rightX: number; rightY: number; radius: number } {
|
||||||
|
const left = lm[UNDER_EYE_LEFT]
|
||||||
|
const right = lm[UNDER_EYE_RIGHT]
|
||||||
|
const faceW = Math.hypot(
|
||||||
|
((lm[FACE_RIGHT]?.x ?? 0.7) - (lm[FACE_LEFT]?.x ?? 0.3)) * (width / height),
|
||||||
|
(lm[FACE_RIGHT]?.y ?? 0.5) - (lm[FACE_LEFT]?.y ?? 0.5)
|
||||||
|
)
|
||||||
|
const radius = Math.max(0.03, faceW * 0.14)
|
||||||
|
return {
|
||||||
|
leftX: left?.x ?? 0.35,
|
||||||
|
// 略向下偏移,对准眼袋/黑眼圈区域
|
||||||
|
leftY: Math.min(0.99, (left?.y ?? 0.42) + 0.012),
|
||||||
|
rightX: right?.x ?? 0.65,
|
||||||
|
rightY: Math.min(0.99, (right?.y ?? 0.42) + 0.012),
|
||||||
|
radius
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按人脸椭圆绘制软边蒙版并上传为纹理(白色=磨皮区域) */
|
||||||
|
private uploadFaceMask(lm: NormalizedLandmark[]) {
|
||||||
|
const { width, height } = this.canvas
|
||||||
|
const ctx = this.maskCtx
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||||
|
ctx.clearRect(0, 0, width, height)
|
||||||
|
ctx.fillStyle = '#000'
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
|
||||||
|
// 略放大轮廓,覆盖发际线/下颌边缘;软边避免硬切
|
||||||
|
let minX = 1
|
||||||
|
let minY = 1
|
||||||
|
let maxX = 0
|
||||||
|
let maxY = 0
|
||||||
|
for (const idx of FACE_OVAL) {
|
||||||
|
const p = lm[idx]
|
||||||
|
if (!p) continue
|
||||||
|
minX = Math.min(minX, p.x)
|
||||||
|
minY = Math.min(minY, p.y)
|
||||||
|
maxX = Math.max(maxX, p.x)
|
||||||
|
maxY = Math.max(maxY, p.y)
|
||||||
|
}
|
||||||
|
const cx = ((minX + maxX) / 2) * width
|
||||||
|
const cy = ((minY + maxY) / 2) * height
|
||||||
|
const scale = 1.1
|
||||||
|
// 更宽的软边,让美白/红润在下颌、发际线处自然过渡不见分界
|
||||||
|
const blurPx = Math.max(
|
||||||
|
8,
|
||||||
|
Math.round(Math.min((maxX - minX) * width, (maxY - minY) * height) * 0.1)
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.filter = `blur(${blurPx}px)`
|
||||||
|
ctx.translate(cx, cy)
|
||||||
|
ctx.scale(scale, scale)
|
||||||
|
ctx.translate(-cx, -cy)
|
||||||
|
ctx.beginPath()
|
||||||
|
this.traceFaceOval(lm, width, height)
|
||||||
|
ctx.fillStyle = '#fff'
|
||||||
|
ctx.fill()
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
const gl = this.gl
|
||||||
|
gl.activeTexture(gl.TEXTURE0)
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, this.faceMaskTexture)
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.maskCanvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
private traceFaceOval(lm: NormalizedLandmark[], width: number, height: number) {
|
||||||
|
const first = lm[FACE_OVAL[0]]
|
||||||
|
if (!first) return
|
||||||
|
this.maskCtx.moveTo(first.x * width, first.y * height)
|
||||||
|
for (let i = 1; i < FACE_OVAL.length; i++) {
|
||||||
|
const p = lm[FACE_OVAL[i]]
|
||||||
|
if (!p) continue
|
||||||
|
this.maskCtx.lineTo(p.x * width, p.y * height)
|
||||||
|
}
|
||||||
|
this.maskCtx.closePath()
|
||||||
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
const ext = this.gl.getExtension('WEBGL_lose_context')
|
const ext = this.gl.getExtension('WEBGL_lose_context')
|
||||||
ext?.loseContext()
|
ext?.loseContext()
|
||||||
@@ -449,6 +765,11 @@ export class FaceBeautyPipeline {
|
|||||||
private watchdogTimer: ReturnType<typeof setInterval> | null = null
|
private watchdogTimer: ReturnType<typeof setInterval> | null = null
|
||||||
private lastFrameAt = 0
|
private lastFrameAt = 0
|
||||||
private lastDetectTs = -1
|
private lastDetectTs = -1
|
||||||
|
/** 复用上一帧关键点,避免每帧都跑 MediaPipe(首帧/高分辨率时极易卡死主线程) */
|
||||||
|
private lastLandmarks: NormalizedLandmark[] | null = null
|
||||||
|
private lastDetectAt = 0
|
||||||
|
/** 人脸检测间隔:预览约 10fps,通话约 15fps */
|
||||||
|
private readonly detectIntervalMs: number
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
sourceTrack: MediaStreamTrack,
|
sourceTrack: MediaStreamTrack,
|
||||||
@@ -456,6 +777,7 @@ export class FaceBeautyPipeline {
|
|||||||
options?: { isPreview?: boolean }
|
options?: { isPreview?: boolean }
|
||||||
) {
|
) {
|
||||||
this.isPreview = Boolean(options?.isPreview)
|
this.isPreview = Boolean(options?.isPreview)
|
||||||
|
this.detectIntervalMs = this.isPreview ? 100 : 66
|
||||||
this.sourceTrack = sourceTrack
|
this.sourceTrack = sourceTrack
|
||||||
this.settings = this.cloneSettings(settings)
|
this.settings = this.cloneSettings(settings)
|
||||||
this.video = document.createElement('video')
|
this.video = document.createElement('video')
|
||||||
@@ -467,6 +789,15 @@ export class FaceBeautyPipeline {
|
|||||||
this.ctx = ctx
|
this.ctx = ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 让出主线程,避免打开预览时与 UI 绘制叠在同一帧导致卡死 */
|
||||||
|
private yieldToMain(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setTimeout(resolve, 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private cloneSettings(settings: CallBeautySettings): CallBeautySettings {
|
private cloneSettings(settings: CallBeautySettings): CallBeautySettings {
|
||||||
return { ...settings, advanced: { ...settings.advanced } }
|
return { ...settings, advanced: { ...settings.advanced } }
|
||||||
}
|
}
|
||||||
@@ -484,30 +815,38 @@ export class FaceBeautyPipeline {
|
|||||||
setTimeout(resolve, 1500)
|
setTimeout(resolve, 1500)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 始终按摄像头原生分辨率处理,保证输出清晰(全屏预览也不糊);
|
||||||
|
// 首开卡顿由异步初始化 + 人脸检测节流解决,不靠降采样
|
||||||
const width = this.video.videoWidth || 640
|
const width = this.video.videoWidth || 640
|
||||||
const height = this.video.videoHeight || 480
|
const height = this.video.videoHeight || 480
|
||||||
this.output.width = width
|
this.output.width = width
|
||||||
this.output.height = height
|
this.output.height = height
|
||||||
|
|
||||||
|
// 先输出原始画面,再异步初始化 WebGL/模型,避免打开瞬间主线程长时间阻塞
|
||||||
|
this.ctx.drawImage(this.video, 0, 0, width, height)
|
||||||
|
this.outputStream = this.output.captureStream(this.isPreview ? 24 : 30)
|
||||||
|
const track = this.outputStream.getVideoTracks()[0]
|
||||||
|
|
||||||
|
await this.yieldToMain()
|
||||||
|
if (this.destroyed) return track
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.renderer = new BeautyRenderer(width, height)
|
this.renderer = new BeautyRenderer(width, height)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[face-beauty] WebGL 初始化失败,瘦脸/磨皮/美白不可用', err)
|
console.warn('[face-beauty] WebGL 初始化失败,瘦脸/磨皮/美白不可用', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模型异步加载,未就绪期间瘦脸/美妆暂不生效(磨皮美白不依赖模型,立即可用)
|
// 模型异步加载,未就绪期间瘦脸/美妆/人脸蒙版磨皮暂不生效
|
||||||
getFaceLandmarker()
|
getFaceLandmarker()
|
||||||
.then((lm) => {
|
.then((lm) => {
|
||||||
if (!this.destroyed) this.landmarker = lm
|
if (!this.destroyed) this.landmarker = lm
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.warn('[face-beauty] 人脸模型加载失败,瘦脸/口红/腮红退化为透传', err)
|
console.warn('[face-beauty] 人脸模型加载失败,瘦脸/口红/腮红/人脸磨皮退化为透传', err)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 先画一帧,保证 captureStream 立即有内容
|
await this.yieldToMain()
|
||||||
this.drawFrame()
|
if (this.destroyed) return track
|
||||||
this.outputStream = this.output.captureStream(30)
|
|
||||||
const track = this.outputStream.getVideoTracks()[0]
|
|
||||||
|
|
||||||
this.startFrameLoop()
|
this.startFrameLoop()
|
||||||
this.sourceTrack.addEventListener('ended', () => this.destroy())
|
this.sourceTrack.addEventListener('ended', () => this.destroy())
|
||||||
@@ -544,15 +883,22 @@ export class FaceBeautyPipeline {
|
|||||||
|
|
||||||
private detect(): NormalizedLandmark[] | null {
|
private detect(): NormalizedLandmark[] | null {
|
||||||
if (!this.landmarker) return null
|
if (!this.landmarker) return null
|
||||||
let ts = performance.now()
|
const now = performance.now()
|
||||||
|
// 节流:中间帧复用上次关键点,避免每帧同步推理把页面卡死
|
||||||
|
if (this.lastLandmarks && now - this.lastDetectAt < this.detectIntervalMs) {
|
||||||
|
return this.lastLandmarks
|
||||||
|
}
|
||||||
|
let ts = now
|
||||||
if (ts <= this.lastDetectTs) ts = this.lastDetectTs + 0.01
|
if (ts <= this.lastDetectTs) ts = this.lastDetectTs + 0.01
|
||||||
this.lastDetectTs = ts
|
this.lastDetectTs = ts
|
||||||
|
this.lastDetectAt = now
|
||||||
try {
|
try {
|
||||||
const result = this.landmarker.detectForVideo(this.video, ts)
|
const result = this.landmarker.detectForVideo(this.video, ts)
|
||||||
const face = result.faceLandmarks?.[0]
|
const face = result.faceLandmarks?.[0]
|
||||||
return face && face.length > 0 ? face : null
|
this.lastLandmarks = face && face.length > 0 ? face : null
|
||||||
|
return this.lastLandmarks
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return this.lastLandmarks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,22 +912,44 @@ export class FaceBeautyPipeline {
|
|||||||
smooth: s.enabled ? s.beautyLevel / 9 : 0,
|
smooth: s.enabled ? s.beautyLevel / 9 : 0,
|
||||||
white: s.enabled ? (s.whitenessLevel / 9) * 0.8 : 0,
|
white: s.enabled ? (s.whitenessLevel / 9) * 0.8 : 0,
|
||||||
ruddy: s.enabled ? (s.ruddinessLevel / 9) * 0.8 : 0,
|
ruddy: s.enabled ? (s.ruddinessLevel / 9) * 0.8 : 0,
|
||||||
|
spot: s.enabled ? (s.spotLevel / 9) * 0.95 : 0,
|
||||||
|
mole: s.enabled ? (s.moleLevel / 9) * 0.98 : 0,
|
||||||
|
darkCircle: s.enabled ? (s.darkCircleLevel / 9) * 0.9 : 0,
|
||||||
|
brightness: s.enabled ? (s.brightnessLevel / 9) * 0.5 : 0,
|
||||||
|
clarity: s.enabled ? (s.clarityLevel / 9) * 1.1 : 0,
|
||||||
edgeKeep: s.style === BEAUTY_STYLE_SMOOTH ? 0.55 : 1.0
|
edgeKeep: s.style === BEAUTY_STYLE_SMOOTH ? 0.55 : 1.0
|
||||||
}
|
}
|
||||||
const wantBasic = basic.smooth > 0 || basic.white > 0 || basic.ruddy > 0
|
const wantBasic =
|
||||||
|
basic.smooth > 0 ||
|
||||||
|
basic.white > 0 ||
|
||||||
|
basic.ruddy > 0 ||
|
||||||
|
basic.spot > 0 ||
|
||||||
|
basic.mole > 0 ||
|
||||||
|
basic.darkCircle > 0 ||
|
||||||
|
basic.brightness > 0 ||
|
||||||
|
basic.clarity > 0
|
||||||
const wantSlim = adv.enabled && adv.slimStrength > 0
|
const wantSlim = adv.enabled && adv.slimStrength > 0
|
||||||
const wantMakeup = adv.enabled && (adv.lipstickStrength > 0 || adv.blushStrength > 0)
|
const wantMakeup = adv.enabled && (adv.lipstickStrength > 0 || adv.blushStrength > 0)
|
||||||
const landmarks = wantSlim || wantMakeup ? this.detect() : null
|
// 肤色类基础美颜需要人脸关键点;亮度/清晰度整帧生效可不依赖人脸
|
||||||
|
const wantFaceSkin =
|
||||||
|
basic.smooth > 0 ||
|
||||||
|
basic.white > 0 ||
|
||||||
|
basic.ruddy > 0 ||
|
||||||
|
basic.spot > 0 ||
|
||||||
|
basic.mole > 0 ||
|
||||||
|
basic.darkCircle > 0
|
||||||
|
const landmarks = wantSlim || wantMakeup || wantFaceSkin ? this.detect() : null
|
||||||
|
|
||||||
const { width, height } = this.output
|
const { width, height } = this.output
|
||||||
|
|
||||||
// 1) WebGL:瘦脸变形 + 磨皮/美白/红润
|
// 1) WebGL:瘦脸变形 + 基础美颜(受人脸蒙版约束)+ 亮度/清晰度
|
||||||
const rendererReady = this.renderer && !this.renderer.isLost
|
const rendererReady = this.renderer && !this.renderer.isLost
|
||||||
if (rendererReady && (wantBasic || (wantSlim && landmarks))) {
|
if (rendererReady && (wantBasic || (wantSlim && landmarks))) {
|
||||||
this.renderer!.render(
|
this.renderer!.render(
|
||||||
this.video,
|
this.video,
|
||||||
wantSlim && landmarks ? this.buildWarps(landmarks) : [],
|
wantSlim && landmarks ? this.buildWarps(landmarks) : [],
|
||||||
basic
|
basic,
|
||||||
|
wantFaceSkin ? landmarks : null
|
||||||
)
|
)
|
||||||
this.ctx.drawImage(this.renderer!.canvas, 0, 0, width, height)
|
this.ctx.drawImage(this.renderer!.canvas, 0, 0, width, height)
|
||||||
} else {
|
} else {
|
||||||
@@ -695,6 +1063,7 @@ export class FaceBeautyPipeline {
|
|||||||
this.video.srcObject = null
|
this.video.srcObject = null
|
||||||
this.renderer?.destroy()
|
this.renderer?.destroy()
|
||||||
this.renderer = null
|
this.renderer = null
|
||||||
|
this.lastLandmarks = null
|
||||||
// landmarker 为全局单例,供下次通话复用,不在此销毁
|
// landmarker 为全局单例,供下次通话复用,不在此销毁
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 视频通话美颜设置
|
* 视频通话美颜设置
|
||||||
*
|
*
|
||||||
* 全部效果(磨皮/美白/红润/瘦脸/口红/腮红)默认由本地管线处理:
|
* 全部效果(磨皮/美白/红润/祛斑/去痣/去黑眼圈/亮度/清晰度/瘦脸/口红/腮红)默认由本地管线处理:
|
||||||
* MediaPipe Face Landmarker(开源免费)识别人脸关键点 + WebGL/Canvas 逐帧渲染,
|
* MediaPipe Face Landmarker(开源免费)识别人脸关键点 + WebGL/Canvas 逐帧渲染,
|
||||||
* 经 getUserMedia 拦截注入通话视频流(见 utils/beauty/ 目录),预览所见即所得。
|
* 经 getUserMedia 拦截注入通话视频流(见 utils/beauty/ 目录),预览所见即所得。
|
||||||
*
|
*
|
||||||
@@ -36,6 +36,16 @@ export interface CallBeautySettings {
|
|||||||
whitenessLevel: number
|
whitenessLevel: number
|
||||||
/** 红润级别 0-9 */
|
/** 红润级别 0-9 */
|
||||||
ruddinessLevel: number
|
ruddinessLevel: number
|
||||||
|
/** 祛斑 / 淡化黑色素 0-9(仅人脸,压暗斑点向周围肤色靠拢) */
|
||||||
|
spotLevel: number
|
||||||
|
/** 去痣 0-9(仅人脸,强力淡化小面积深色痣点) */
|
||||||
|
moleLevel: number
|
||||||
|
/** 去黑眼圈 0-9(仅眼下区域提亮淡化) */
|
||||||
|
darkCircleLevel: number
|
||||||
|
/** 摄像头亮度 0-9(整帧提亮,0 为原亮度) */
|
||||||
|
brightnessLevel: number
|
||||||
|
/** 清晰度 0-9(锐化细节,0 为关闭) */
|
||||||
|
clarityLevel: number
|
||||||
/** 高级美颜(瘦脸/口红/腮红,本地 AI) */
|
/** 高级美颜(瘦脸/口红/腮红,本地 AI) */
|
||||||
advanced: AdvancedBeautySettings
|
advanced: AdvancedBeautySettings
|
||||||
}
|
}
|
||||||
@@ -61,6 +71,11 @@ export const defaultBeautySettings = (): CallBeautySettings => ({
|
|||||||
beautyLevel: 5,
|
beautyLevel: 5,
|
||||||
whitenessLevel: 3,
|
whitenessLevel: 3,
|
||||||
ruddinessLevel: 2,
|
ruddinessLevel: 2,
|
||||||
|
spotLevel: 0,
|
||||||
|
moleLevel: 0,
|
||||||
|
darkCircleLevel: 0,
|
||||||
|
brightnessLevel: 0,
|
||||||
|
clarityLevel: 0,
|
||||||
advanced: defaultAdvancedBeautySettings()
|
advanced: defaultAdvancedBeautySettings()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -93,7 +108,14 @@ export function hasAnyAdvancedEffect(advanced: AdvancedBeautySettings): boolean
|
|||||||
export function hasAnyBeautyEffect(settings: CallBeautySettings): boolean {
|
export function hasAnyBeautyEffect(settings: CallBeautySettings): boolean {
|
||||||
const basicOn =
|
const basicOn =
|
||||||
settings.enabled &&
|
settings.enabled &&
|
||||||
(settings.beautyLevel > 0 || settings.whitenessLevel > 0 || settings.ruddinessLevel > 0)
|
(settings.beautyLevel > 0 ||
|
||||||
|
settings.whitenessLevel > 0 ||
|
||||||
|
settings.ruddinessLevel > 0 ||
|
||||||
|
settings.spotLevel > 0 ||
|
||||||
|
settings.moleLevel > 0 ||
|
||||||
|
settings.darkCircleLevel > 0 ||
|
||||||
|
settings.brightnessLevel > 0 ||
|
||||||
|
settings.clarityLevel > 0)
|
||||||
return basicOn || hasAnyAdvancedEffect(settings.advanced)
|
return basicOn || hasAnyAdvancedEffect(settings.advanced)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +136,11 @@ export function loadBeautySettings(): CallBeautySettings {
|
|||||||
beautyLevel: clampLevel(parsed?.beautyLevel ?? defaults.beautyLevel),
|
beautyLevel: clampLevel(parsed?.beautyLevel ?? defaults.beautyLevel),
|
||||||
whitenessLevel: clampLevel(parsed?.whitenessLevel ?? defaults.whitenessLevel),
|
whitenessLevel: clampLevel(parsed?.whitenessLevel ?? defaults.whitenessLevel),
|
||||||
ruddinessLevel: clampLevel(parsed?.ruddinessLevel ?? defaults.ruddinessLevel),
|
ruddinessLevel: clampLevel(parsed?.ruddinessLevel ?? defaults.ruddinessLevel),
|
||||||
|
spotLevel: clampLevel(parsed?.spotLevel ?? defaults.spotLevel),
|
||||||
|
moleLevel: clampLevel(parsed?.moleLevel ?? defaults.moleLevel),
|
||||||
|
darkCircleLevel: clampLevel(parsed?.darkCircleLevel ?? defaults.darkCircleLevel),
|
||||||
|
brightnessLevel: clampLevel(parsed?.brightnessLevel ?? defaults.brightnessLevel),
|
||||||
|
clarityLevel: clampLevel(parsed?.clarityLevel ?? defaults.clarityLevel),
|
||||||
advanced: {
|
advanced: {
|
||||||
enabled: Boolean(adv?.enabled),
|
enabled: Boolean(adv?.enabled),
|
||||||
slimStrength: clampPercent(adv?.slimStrength, advDefaults.slimStrength),
|
slimStrength: clampPercent(adv?.slimStrength, advDefaults.slimStrength),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user