This commit is contained in:
Your Name
2026-07-10 09:23:23 +08:00
parent c07dfe4e36
commit c0e3cbd52e
4 changed files with 569 additions and 61 deletions
+128 -16
View File
@@ -94,7 +94,9 @@
</el-radio-group>
</div>
<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
v-model="settings.beautyLevel"
:min="0"
@@ -123,6 +125,66 @@
: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.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>
<el-divider class="beauty-divider" />
@@ -261,7 +323,8 @@ installBeautyMediaInterceptor()
const settings = reactive(loadBeautySettings())
const advancedSupported = isAdvancedBeautySupported()
if (settings.advanced.enabled) preloadBeautyModel()
// 面板挂载即预热模型,避免首次打开预览时同步加载 WASM/模型卡死
if (advancedSupported) preloadBeautyModel()
/** 美颜已开启但当前没有活跃通话管线 → 摄像头是在开启前用原始画面打开的 */
const needCameraRestartHint = computed(() => {
@@ -304,13 +367,18 @@ const onDragStart = (e: MouseEvent) => {
e.preventDefault()
}
const openPanel = () => {
const openPanel = async () => {
if (visible.value) return
windowPos.x = Math.max(16, Math.floor((window.innerWidth - WINDOW_WIDTH) / 2))
windowPos.y = Math.max(16, Math.floor(window.innerHeight * 0.12))
visible.value = true
// 打开面板即自动开启预览,省一次点击
if (advancedSupported) void startPreview()
// 先预热模型,并等面板先画出「正在打开摄像头」再启预览,避免首开卡死
if (advancedSupported) {
preloadBeautyModel()
await nextTick()
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
void startPreview()
}
}
const closePanel = () => {
@@ -325,11 +393,15 @@ let applyTimer: ReturnType<typeof setTimeout> | null = null
/**
* 基础美颜下发到 TUICallEngineTRTC BasicBeauty 插件)。
* 仅作为 WebGL 不可用时的回退——正常情况下磨皮/美白/红润统一走本地管线
* 若再叠加引擎插件会造成双重美颜。
* 仅作为 WebGL 不可用时的回退——正常情况下全部效果走本地管线
* WebGL 可用时始终向引擎下发全 0,确保引擎侧插件保持关闭,
* 不会与本地管线叠加,也保证「关闭美颜」时引擎侧残留效果被清掉。
*/
const applyNow = async () => {
if (advancedSupported) return
if (advancedSupported) {
await applyBeautyToEngine(props.getEngine(), { ...settings, enabled: false })
return
}
await applyBeautyToEngine(props.getEngine(), settings)
}
@@ -379,14 +451,22 @@ const startPreview = async () => {
previewError.value = ''
previewLoading.value = true
try {
// 走原始 getUserMedia,避免被拦截器再处理一遍
// 720p 采集保证预览清晰;管线内部会做适度降采样控制处理开销
const raw = await getRawUserMedia({
video: { width: { ideal: 1280 }, height: { ideal: 720 } },
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 }
},
audio: false
})
const track = raw.getVideoTracks()[0]
if (!track) throw new Error('未获取到摄像头画面')
// 让出一帧,保证 loading 文案先渲染
await nextTick()
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
previewPipeline = new FaceBeautyPipeline(
track,
{ ...settings, advanced: { ...settings.advanced } },
@@ -443,7 +523,12 @@ const handleReset = () => {
style: defaults.style,
beautyLevel: defaults.beautyLevel,
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">
.beauty-window {
position: fixed;
max-width: 94vw;
max-width: calc(100vw - 24px);
max-height: calc(100vh - 24px);
background: #fff;
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.28);
@@ -514,9 +600,13 @@ defineExpose({
display: flex;
gap: 16px;
padding: 16px;
flex: 1;
min-height: 0;
overflow: hidden;
@media (max-width: 820px) {
flex-direction: column;
overflow-y: auto;
}
}
@@ -529,12 +619,13 @@ defineExpose({
border-radius: 8px;
overflow: hidden;
background: #111;
aspect-ratio: 4 / 3;
aspect-ratio: 16 / 9;
.beauty-preview-video {
width: 100%;
height: 100%;
object-fit: cover;
/* contain 完整显示画面,与实际发送给对方的内容一致,不裁切 */
object-fit: contain;
/* 本地自拍习惯镜像显示;实际发送给对方的画面不镜像 */
transform: scaleX(-1);
}
@@ -606,10 +697,14 @@ defineExpose({
}
.beauty-control-col {
flex: 0 0 280px;
flex: 0 0 300px;
min-width: 260px;
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
padding-right: 6px;
scrollbar-width: thin;
}
.beauty-section-header {
@@ -651,9 +746,15 @@ defineExpose({
.beauty-label {
flex-shrink: 0;
width: 52px;
width: 64px;
font-size: 13px;
color: #606266;
&.has-tip {
cursor: help;
text-decoration: underline dotted #c0c4cc;
text-underline-offset: 3px;
}
}
.el-slider {
@@ -664,6 +765,17 @@ defineExpose({
.el-color-picker {
flex-shrink: 0;
}
&.beauty-item-hint {
margin-top: -2px;
margin-bottom: 6px;
.beauty-hint {
font-size: 12px;
color: #909399;
line-height: 1.4;
}
}
}
}
+411 -42
View File
@@ -1,17 +1,23 @@
/**
* 本地美颜管线(磨皮/美白/红润/瘦脸/口红/腮红)
* 本地美颜管线(磨皮/美白/红润/祛斑/去痣/去黑眼圈/亮度/清晰度/瘦脸/口红/腮红)
*
* 实现思路(全部免费、不依赖腾讯特效 SDK):
* 1. MediaPipe Face LandmarkerApache 2.0)在浏览器内逐帧输出 478 个人脸关键点;
* 2. WebGL 多 pass 渲染:
* - 瘦脸:片元着色器局部液化变形,把下颌关键点附近的像素向面部中心推;
* - 磨皮:分离高斯模糊 + 亮度差边缘保护混合(保留五官细节,只柔化皮肤);
* - 美白/红润:中间调提亮曲线 + 暖色偏移
* - 磨皮:分离高斯模糊 + 亮度差边缘保护混合,并用 MediaPipe 人脸椭圆蒙版
* 限制只作用于人脸区域(背景/衣物/桌面等不磨皮)
* - 美白/红润:中间调提亮曲线 + 暖色偏移(同样受人脸蒙版约束);
* - 祛斑:人脸内暗斑相对模糊肤色的偏差压回,淡化黑色素/色斑;
* - 去痣:人脸内小面积深色点更强力向周围肤色融合;
* - 去黑眼圈:眼下区域局部提亮并与周围肤色混合;
* - 亮度:整帧提亮(不依赖人脸);
* - 清晰度:unsharp mask 锐化细节(整帧);
* 3. 口红/腮红:按唇部/颧骨关键点在 2D Canvas 上着色,用混合模式叠回画面;
* 4. 处理结果经 canvas.captureStream() 输出为新的视频轨,由 getUserMedia
* 拦截器(beauty-media-interceptor.ts)透明地替换给 TUICallKit。
*
* 注:基础美颜(磨皮/美白/红润)不再走 TRTC 引擎的 BasicBeauty 插件,统一在
* 注:基础美颜(磨皮/美白/红润/祛斑/去痣/去黑眼圈/亮度/清晰度)不再走 TRTC 引擎的 BasicBeauty 插件,统一在
* 本管线内处理,保证预览所见即所得;仅在 WebGL 不可用时回退引擎插件。
*
* 模型与 WASM 优先从本站 /mediapipe/ 加载(随前端部署),失败时回退官方 CDN。
@@ -86,6 +92,9 @@ const LIPS_INNER = [
/** 左/右颧骨中心(腮红位置) */
const CHEEK_LEFT = 50
const CHEEK_RIGHT = 280
/** 去黑眼圈:左右眼下区域参考点(下眼睑中点附近) */
const UNDER_EYE_LEFT = 111
const UNDER_EYE_RIGHT = 340
/** 面部参考点 */
const NOSE_TIP = 4
const CHIN = 152
@@ -94,6 +103,14 @@ const FACE_RIGHT = 454
/** 瘦脸拖拽点:左右各 3 个下颌/脸颊关键点 */
const JAW_LEFT = [132, 172, 136]
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
@@ -159,22 +176,97 @@ const FINAL_FRAGMENT = `
precision mediump float;
uniform sampler2D u_sharp;
uniform sampler2D u_blur;
uniform sampler2D u_faceMask;
uniform float u_smooth;
uniform float u_white;
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_hasFaceMask;
uniform vec2 u_underEyeL;
uniform vec2 u_underEyeR;
uniform float u_underEyeRadius;
uniform vec2 u_aspect;
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() {
vec3 a = texture2D(u_sharp, 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 edge = smoothstep(0.05, 0.22, lumDiff) * u_edgeKeep;
vec3 c = mix(a, b, u_smooth * (1.0 - edge));
// 美白:提亮中间调,避免高光过曝
c = c + u_white * c * (vec3(1.0) - c) * 0.55;
// 红润:暖色偏移
c *= vec3(1.0) + u_ruddy * vec3(0.10, 0.0, -0.04);
float lumA = dot(a, vec3(0.299, 0.587, 0.114));
float lumB = dot(b, vec3(0.299, 0.587, 0.114));
float lumDiff = abs(lumA - lumB);
float edge = smoothstep(0.06, 0.25, lumDiff) * u_edgeKeep;
float smoothAmt = u_smooth * (1.0 - edge) * face;
vec3 c = mix(a, b, smoothAmt);
// 祛斑 / 淡化黑色素:暗于周围肤色的区域向肤色靠拢(带阈值避免放大噪点)
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);
}
`
@@ -186,6 +278,16 @@ export interface BasicRenderParams {
white: number
/** 红润 0-1 */
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 更平滑 */
edgeKeep: number
}
@@ -206,6 +308,7 @@ class BeautyRenderer {
readonly canvas: HTMLCanvasElement
private gl: WebGLRenderingContext
private videoTexture: WebGLTexture
private faceMaskTexture: WebGLTexture
private warpProg: ProgramInfo
private blurProg: ProgramInfo
private finalProg: ProgramInfo
@@ -213,6 +316,9 @@ class BeautyRenderer {
private fboB: FboInfo
private fboC: FboInfo
private lost = false
/** 2D 画布:按人脸椭圆绘制磨皮蒙版后上传为 WebGL 纹理 */
private maskCanvas: HTMLCanvasElement
private maskCtx: CanvasRenderingContext2D
constructor(width: number, height: number) {
this.canvas = document.createElement('canvas')
@@ -228,6 +334,13 @@ class BeautyRenderer {
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 buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
@@ -245,13 +358,25 @@ class BeautyRenderer {
this.finalProg = this.buildProgram(FINAL_FRAGMENT, [
'u_sharp',
'u_blur',
'u_faceMask',
'u_smooth',
'u_white',
'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.faceMaskTexture = this.createTexture()
this.fboA = this.createFbo(width, height)
this.fboB = 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 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 { width, height } = this.canvas
@@ -367,35 +498,220 @@ class BeautyRenderer {
gl.uniform2f(wp.uniforms.u_aspect, width / height, 1)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
// Pass 2/3: 分离高斯模糊(仅磨皮开启时),模糊半径随磨皮强度增大
let blurTex = this.fboA.tex
if (basic.smooth > 0.001) {
const spacing = 1 + basic.smooth * 3
const bp = this.blurProg
this.usePass(bp, this.fboB.fb, false)
this.bindTex(0, this.fboA.tex, bp.uniforms.u_tex)
gl.uniform2f(bp.uniforms.u_dir, spacing / width, 0)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
const hasFaceMask = Boolean(faceLandmarks && faceLandmarks.length > 0)
const wantFaceSkin =
basic.smooth > 0.001 ||
basic.white > 0.001 ||
basic.ruddy > 0.001 ||
basic.spot > 0.001 ||
basic.mole > 0.001 ||
basic.darkCircle > 0.001
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)
gl.uniform2f(bp.uniforms.u_dir, 0, spacing / height)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
// 无人脸且无整帧效果时,直接输出变形后画面
if (!applyFaceSkin && !wantGlobal) {
this.drawFinalPass(this.fboA.tex, this.fboA.tex, {
...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
}
// 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
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(2, this.faceMaskTexture, fp.uniforms.u_faceMask)
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_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_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)
}
/** 眼下区域中心与半径(归一化 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() {
const ext = this.gl.getExtension('WEBGL_lose_context')
ext?.loseContext()
@@ -449,6 +765,11 @@ export class FaceBeautyPipeline {
private watchdogTimer: ReturnType<typeof setInterval> | null = null
private lastFrameAt = 0
private lastDetectTs = -1
/** 复用上一帧关键点,避免每帧都跑 MediaPipe(首帧/高分辨率时极易卡死主线程) */
private lastLandmarks: NormalizedLandmark[] | null = null
private lastDetectAt = 0
/** 人脸检测间隔:预览约 10fps,通话约 15fps */
private readonly detectIntervalMs: number
constructor(
sourceTrack: MediaStreamTrack,
@@ -456,6 +777,7 @@ export class FaceBeautyPipeline {
options?: { isPreview?: boolean }
) {
this.isPreview = Boolean(options?.isPreview)
this.detectIntervalMs = this.isPreview ? 100 : 66
this.sourceTrack = sourceTrack
this.settings = this.cloneSettings(settings)
this.video = document.createElement('video')
@@ -467,6 +789,15 @@ export class FaceBeautyPipeline {
this.ctx = ctx
}
/** 让出主线程,避免打开预览时与 UI 绘制叠在同一帧导致卡死 */
private yieldToMain(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => {
setTimeout(resolve, 0)
})
})
}
private cloneSettings(settings: CallBeautySettings): CallBeautySettings {
return { ...settings, advanced: { ...settings.advanced } }
}
@@ -484,30 +815,38 @@ export class FaceBeautyPipeline {
setTimeout(resolve, 1500)
})
}
// 始终按摄像头原生分辨率处理,保证输出清晰(全屏预览也不糊);
// 首开卡顿由异步初始化 + 人脸检测节流解决,不靠降采样
const width = this.video.videoWidth || 640
const height = this.video.videoHeight || 480
this.output.width = width
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 {
this.renderer = new BeautyRenderer(width, height)
} catch (err) {
console.warn('[face-beauty] WebGL 初始化失败,瘦脸/磨皮/美白不可用', err)
}
// 模型异步加载,未就绪期间瘦脸/美妆暂不生效(磨皮美白不依赖模型,立即可用)
// 模型异步加载,未就绪期间瘦脸/美妆/人脸蒙版磨皮暂不生效
getFaceLandmarker()
.then((lm) => {
if (!this.destroyed) this.landmarker = lm
})
.catch((err) => {
console.warn('[face-beauty] 人脸模型加载失败,瘦脸/口红/腮红退化为透传', err)
console.warn('[face-beauty] 人脸模型加载失败,瘦脸/口红/腮红/人脸磨皮退化为透传', err)
})
// 先画一帧,保证 captureStream 立即有内容
this.drawFrame()
this.outputStream = this.output.captureStream(30)
const track = this.outputStream.getVideoTracks()[0]
await this.yieldToMain()
if (this.destroyed) return track
this.startFrameLoop()
this.sourceTrack.addEventListener('ended', () => this.destroy())
@@ -544,15 +883,22 @@ export class FaceBeautyPipeline {
private detect(): NormalizedLandmark[] | 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
this.lastDetectTs = ts
this.lastDetectAt = now
try {
const result = this.landmarker.detectForVideo(this.video, ts)
const face = result.faceLandmarks?.[0]
return face && face.length > 0 ? face : null
this.lastLandmarks = face && face.length > 0 ? face : null
return this.lastLandmarks
} catch {
return null
return this.lastLandmarks
}
}
@@ -566,22 +912,44 @@ export class FaceBeautyPipeline {
smooth: s.enabled ? s.beautyLevel / 9 : 0,
white: s.enabled ? (s.whitenessLevel / 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
}
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 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
// 1) WebGL:瘦脸变形 + 磨皮/美白/红润
// 1) WebGL:瘦脸变形 + 基础美颜(受人脸蒙版约束)+ 亮度/清晰度
const rendererReady = this.renderer && !this.renderer.isLost
if (rendererReady && (wantBasic || (wantSlim && landmarks))) {
this.renderer!.render(
this.video,
wantSlim && landmarks ? this.buildWarps(landmarks) : [],
basic
basic,
wantFaceSkin ? landmarks : null
)
this.ctx.drawImage(this.renderer!.canvas, 0, 0, width, height)
} else {
@@ -695,6 +1063,7 @@ export class FaceBeautyPipeline {
this.video.srcObject = null
this.renderer?.destroy()
this.renderer = null
this.lastLandmarks = null
// landmarker 为全局单例,供下次通话复用,不在此销毁
}
}
+29 -2
View File
@@ -1,7 +1,7 @@
/**
* 视频通话美颜设置
*
* 全部效果(磨皮/美白/红润/瘦脸/口红/腮红)默认由本地管线处理:
* 全部效果(磨皮/美白/红润/祛斑/去痣/去黑眼圈/亮度/清晰度/瘦脸/口红/腮红)默认由本地管线处理:
* MediaPipe Face Landmarker(开源免费)识别人脸关键点 + WebGL/Canvas 逐帧渲染,
* 经 getUserMedia 拦截注入通话视频流(见 utils/beauty/ 目录),预览所见即所得。
*
@@ -36,6 +36,16 @@ export interface CallBeautySettings {
whitenessLevel: number
/** 红润级别 0-9 */
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) */
advanced: AdvancedBeautySettings
}
@@ -61,6 +71,11 @@ export const defaultBeautySettings = (): CallBeautySettings => ({
beautyLevel: 5,
whitenessLevel: 3,
ruddinessLevel: 2,
spotLevel: 0,
moleLevel: 0,
darkCircleLevel: 0,
brightnessLevel: 0,
clarityLevel: 0,
advanced: defaultAdvancedBeautySettings()
})
@@ -93,7 +108,14 @@ export function hasAnyAdvancedEffect(advanced: AdvancedBeautySettings): boolean
export function hasAnyBeautyEffect(settings: CallBeautySettings): boolean {
const basicOn =
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)
}
@@ -114,6 +136,11 @@ export function loadBeautySettings(): CallBeautySettings {
beautyLevel: clampLevel(parsed?.beautyLevel ?? defaults.beautyLevel),
whitenessLevel: clampLevel(parsed?.whitenessLevel ?? defaults.whitenessLevel),
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: {
enabled: Boolean(adv?.enabled),
slimStrength: clampPercent(adv?.slimStrength, advDefaults.slimStrength),
File diff suppressed because one or more lines are too long