新增美颜
This commit is contained in:
Generated
+7
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@mediapipe/tasks-vision": "^0.10.35",
|
||||
"@tencentcloud/call-uikit-vue": "^4.0.12",
|
||||
"@tencentcloud/chat-uikit-vue3": "^4.5.4",
|
||||
"@trtc/calls-uikit-vue": "^4.4.6",
|
||||
@@ -2484,6 +2485,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.35",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
|
||||
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@mediapipe/tasks-vision": "^0.10.35",
|
||||
"@tencentcloud/call-uikit-vue": "^4.0.12",
|
||||
"@tencentcloud/chat-uikit-vue3": "^4.5.4",
|
||||
"@trtc/calls-uikit-vue": "^4.4.6",
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,700 @@
|
||||
<template>
|
||||
<span class="beauty-trigger" @click="openPanel">
|
||||
<slot name="reference">
|
||||
<el-button type="primary" link size="small">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span class="beauty-btn-text">美颜</span>
|
||||
</el-button>
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="beauty-window"
|
||||
:style="windowStyle"
|
||||
@mousedown.stop
|
||||
>
|
||||
<div class="beauty-window-header" @mousedown="onDragStart">
|
||||
<span class="beauty-window-title">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
美颜设置
|
||||
</span>
|
||||
<el-button type="danger" link class="beauty-window-close" @mousedown.stop @click="closePanel">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="beauty-window-body">
|
||||
<!-- 左侧:大预览 -->
|
||||
<div class="beauty-preview-col">
|
||||
<div ref="previewStageRef" class="beauty-preview-stage">
|
||||
<video
|
||||
v-show="previewing"
|
||||
ref="previewVideoRef"
|
||||
class="beauty-preview-video"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
></video>
|
||||
<div v-if="!previewing" class="beauty-preview-placeholder">
|
||||
<el-icon :size="36"><VideoCamera /></el-icon>
|
||||
<span v-if="previewLoading">正在打开摄像头...</span>
|
||||
<span v-else-if="previewError" class="beauty-preview-error-text">{{ previewError }}</span>
|
||||
<span v-else>摄像头预览未开启</span>
|
||||
</div>
|
||||
<span v-if="previewing" class="beauty-preview-badge">对方看到的效果(镜像显示)</span>
|
||||
<el-button
|
||||
v-if="previewing"
|
||||
class="beauty-fullscreen-btn"
|
||||
circle
|
||||
size="small"
|
||||
title="全屏预览(按 Esc 退出)"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="beauty-preview-toolbar">
|
||||
<el-button
|
||||
size="small"
|
||||
:loading="previewLoading"
|
||||
@click="togglePreview"
|
||||
>
|
||||
{{ previewing ? '关闭预览' : '打开预览' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="previewing"
|
||||
size="small"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
<span style="margin-left: 2px">全屏</span>
|
||||
</el-button>
|
||||
<span class="beauty-preview-note">预览效果即对方所见(画面镜像显示)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:调节项 -->
|
||||
<div class="beauty-control-col">
|
||||
<div class="beauty-section-header">
|
||||
<span class="beauty-section-title">基础美颜</span>
|
||||
<el-switch v-model="settings.enabled" size="small" />
|
||||
</div>
|
||||
<div class="beauty-section-body" :class="{ 'is-disabled': !settings.enabled }">
|
||||
<div class="beauty-item">
|
||||
<span class="beauty-label">磨皮算法</span>
|
||||
<el-radio-group
|
||||
v-model="settings.style"
|
||||
size="small"
|
||||
:disabled="!settings.enabled"
|
||||
>
|
||||
<el-radio-button :value="BEAUTY_STYLE_NATURE">自然</el-radio-button>
|
||||
<el-radio-button :value="BEAUTY_STYLE_SMOOTH">光滑</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="beauty-item">
|
||||
<span class="beauty-label">磨皮</span>
|
||||
<el-slider
|
||||
v-model="settings.beautyLevel"
|
||||
:min="0"
|
||||
:max="BEAUTY_LEVEL_MAX"
|
||||
:step="1"
|
||||
:disabled="!settings.enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="beauty-item">
|
||||
<span class="beauty-label">美白</span>
|
||||
<el-slider
|
||||
v-model="settings.whitenessLevel"
|
||||
:min="0"
|
||||
:max="BEAUTY_LEVEL_MAX"
|
||||
:step="1"
|
||||
:disabled="!settings.enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="beauty-item">
|
||||
<span class="beauty-label">红润</span>
|
||||
<el-slider
|
||||
v-model="settings.ruddinessLevel"
|
||||
:min="0"
|
||||
:max="BEAUTY_LEVEL_MAX"
|
||||
:step="1"
|
||||
:disabled="!settings.enabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider class="beauty-divider" />
|
||||
|
||||
<div class="beauty-section-header">
|
||||
<span class="beauty-section-title">
|
||||
高级美颜
|
||||
<el-tooltip
|
||||
placement="top"
|
||||
content="瘦脸/口红/腮红由本机 AI 实时处理(免费,无需腾讯付费特效)。开关在下次开启摄像头时生效,通话中可关闭摄像头再打开。"
|
||||
>
|
||||
<el-icon class="beauty-help-icon"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
<el-switch
|
||||
v-model="settings.advanced.enabled"
|
||||
size="small"
|
||||
:disabled="!advancedSupported"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!advancedSupported" class="beauty-unsupported">
|
||||
当前浏览器不支持高级美颜(需要 WebGL),请使用新版 Chrome / Edge
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="beauty-section-body"
|
||||
:class="{ 'is-disabled': !settings.advanced.enabled }"
|
||||
>
|
||||
<div class="beauty-item">
|
||||
<span class="beauty-label">瘦脸</span>
|
||||
<el-slider
|
||||
v-model="settings.advanced.slimStrength"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:disabled="!settings.advanced.enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="beauty-item">
|
||||
<span class="beauty-label">口红</span>
|
||||
<el-slider
|
||||
v-model="settings.advanced.lipstickStrength"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:disabled="!settings.advanced.enabled"
|
||||
/>
|
||||
<el-color-picker
|
||||
v-model="settings.advanced.lipstickColor"
|
||||
size="small"
|
||||
:predefine="LIPSTICK_PRESETS"
|
||||
:disabled="!settings.advanced.enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="beauty-item">
|
||||
<span class="beauty-label">腮红</span>
|
||||
<el-slider
|
||||
v-model="settings.advanced.blushStrength"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:disabled="!settings.advanced.enabled"
|
||||
/>
|
||||
<el-color-picker
|
||||
v-model="settings.advanced.blushColor"
|
||||
size="small"
|
||||
:predefine="BLUSH_PRESETS"
|
||||
:disabled="!settings.advanced.enabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="needCameraRestartHint" class="beauty-restart-hint">
|
||||
通话中如果摄像头已按原始画面开启,需关闭摄像头再打开才能生效
|
||||
</div>
|
||||
|
||||
<div class="beauty-window-footer">
|
||||
<span class="beauty-tip">全部为免费能力,通话中实时生效</span>
|
||||
<el-button link type="primary" size="small" @click="handleReset">
|
||||
恢复默认
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch, computed, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { MagicStick, QuestionFilled, Close, VideoCamera, FullScreen } from '@element-plus/icons-vue'
|
||||
import {
|
||||
BEAUTY_LEVEL_MAX,
|
||||
BEAUTY_STYLE_NATURE,
|
||||
BEAUTY_STYLE_SMOOTH,
|
||||
applyBeautyToEngine,
|
||||
defaultBeautySettings,
|
||||
hasAnyBeautyEffect,
|
||||
loadBeautySettings,
|
||||
saveBeautySettings
|
||||
} from '@/utils/call-beauty'
|
||||
import {
|
||||
getRawUserMedia,
|
||||
installBeautyMediaInterceptor,
|
||||
preloadBeautyModel
|
||||
} from '@/utils/beauty/beauty-media-interceptor'
|
||||
import {
|
||||
FaceBeautyPipeline,
|
||||
hasActiveBeautyPipeline,
|
||||
isAdvancedBeautySupported,
|
||||
updateActiveBeautyPipelines
|
||||
} from '@/utils/beauty/face-beauty-pipeline'
|
||||
|
||||
const LIPSTICK_PRESETS = ['#c94f5e', '#b03a48', '#d96a76', '#a52a3c', '#e0788a', '#8f2436']
|
||||
const BLUSH_PRESETS = ['#e88193', '#f0a3b0', '#dd6b7f', '#f2b8c0', '#e5949e']
|
||||
|
||||
const WINDOW_WIDTH = 780
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 获取当前 TUICallEngine 实例(未初始化时返回 null 即可) */
|
||||
getEngine: () => any
|
||||
/** 面板 z-index,需高于承载视频通话的浮窗 */
|
||||
popperZIndex?: number
|
||||
}>(),
|
||||
{
|
||||
popperZIndex: 300001
|
||||
}
|
||||
)
|
||||
|
||||
// 拦截器必须先于 SDK 申请摄像头安装;组件被通话相关页面引入时即安装
|
||||
installBeautyMediaInterceptor()
|
||||
|
||||
const settings = reactive(loadBeautySettings())
|
||||
const advancedSupported = isAdvancedBeautySupported()
|
||||
|
||||
if (settings.advanced.enabled) preloadBeautyModel()
|
||||
|
||||
/** 美颜已开启但当前没有活跃通话管线 → 摄像头是在开启前用原始画面打开的 */
|
||||
const needCameraRestartHint = computed(() => {
|
||||
return hasAnyBeautyEffect(settings) && !hasActiveBeautyPipeline()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 浮动窗口(可拖拽)
|
||||
// ---------------------------------------------------------------------------
|
||||
const visible = ref(false)
|
||||
const windowPos = reactive({ x: 0, y: 0 })
|
||||
|
||||
const windowStyle = computed(() => ({
|
||||
left: `${windowPos.x}px`,
|
||||
top: `${windowPos.y}px`,
|
||||
width: `${WINDOW_WIDTH}px`,
|
||||
zIndex: props.popperZIndex
|
||||
}))
|
||||
|
||||
const dragState = { active: false, offsetX: 0, offsetY: 0 }
|
||||
|
||||
const onDragMove = (e: MouseEvent) => {
|
||||
if (!dragState.active) return
|
||||
windowPos.x = Math.max(0, Math.min(window.innerWidth - 120, e.clientX - dragState.offsetX))
|
||||
windowPos.y = Math.max(0, Math.min(window.innerHeight - 60, e.clientY - dragState.offsetY))
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
dragState.active = false
|
||||
document.removeEventListener('mousemove', onDragMove)
|
||||
document.removeEventListener('mouseup', onDragEnd)
|
||||
}
|
||||
|
||||
const onDragStart = (e: MouseEvent) => {
|
||||
dragState.active = true
|
||||
dragState.offsetX = e.clientX - windowPos.x
|
||||
dragState.offsetY = e.clientY - windowPos.y
|
||||
document.addEventListener('mousemove', onDragMove)
|
||||
document.addEventListener('mouseup', onDragEnd)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const openPanel = () => {
|
||||
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()
|
||||
}
|
||||
|
||||
const closePanel = () => {
|
||||
stopPreview()
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 基础美颜:下发到 TUICallEngine
|
||||
// ---------------------------------------------------------------------------
|
||||
let applyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/**
|
||||
* 基础美颜下发到 TUICallEngine(TRTC BasicBeauty 插件)。
|
||||
* 仅作为 WebGL 不可用时的回退——正常情况下磨皮/美白/红润统一走本地管线,
|
||||
* 若再叠加引擎插件会造成双重美颜。
|
||||
*/
|
||||
const applyNow = async () => {
|
||||
if (advancedSupported) return
|
||||
await applyBeautyToEngine(props.getEngine(), settings)
|
||||
}
|
||||
|
||||
/** 通话建立后调用:仅在启用了美颜时下发,避免无意义地拉起插件 */
|
||||
const applyIfEnabled = async () => {
|
||||
if (!settings.enabled) return
|
||||
await applyNow()
|
||||
}
|
||||
|
||||
watch(
|
||||
settings,
|
||||
() => {
|
||||
saveBeautySettings(settings)
|
||||
// 全部美颜参数对活跃管线(含预览)实时生效
|
||||
updateActiveBeautyPipelines({ ...settings, advanced: { ...settings.advanced } })
|
||||
if (settings.advanced.enabled) preloadBeautyModel()
|
||||
if (applyTimer) clearTimeout(applyTimer)
|
||||
// 滑杆拖动会高频触发,防抖后再下发到引擎(仅 WebGL 回退场景实际生效)
|
||||
applyTimer = setTimeout(() => {
|
||||
applyTimer = null
|
||||
void applyNow()
|
||||
}, 200)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 效果预览:本地摄像头 → 同一条美颜管线 → 大画面播放,不发起任何通话
|
||||
// ---------------------------------------------------------------------------
|
||||
const previewing = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref('')
|
||||
const previewVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
const previewStageRef = ref<HTMLElement | null>(null)
|
||||
let previewPipeline: FaceBeautyPipeline | null = null
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen().catch(() => {})
|
||||
} else {
|
||||
void previewStageRef.value?.requestFullscreen().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const startPreview = async () => {
|
||||
if (previewing.value || previewLoading.value) return
|
||||
previewError.value = ''
|
||||
previewLoading.value = true
|
||||
try {
|
||||
// 走原始 getUserMedia,避免被拦截器再处理一遍
|
||||
const raw = await getRawUserMedia({
|
||||
video: { width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||
audio: false
|
||||
})
|
||||
const track = raw.getVideoTracks()[0]
|
||||
if (!track) throw new Error('未获取到摄像头画面')
|
||||
|
||||
previewPipeline = new FaceBeautyPipeline(
|
||||
track,
|
||||
{ ...settings, advanced: { ...settings.advanced } },
|
||||
{ isPreview: true }
|
||||
)
|
||||
const processedTrack = await previewPipeline.start()
|
||||
|
||||
previewing.value = true
|
||||
await nextTick()
|
||||
if (previewVideoRef.value) {
|
||||
previewVideoRef.value.srcObject = new MediaStream([processedTrack])
|
||||
await previewVideoRef.value.play().catch(() => {})
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('[call-beauty] 预览启动失败', err)
|
||||
previewPipeline?.destroy()
|
||||
previewPipeline = null
|
||||
previewing.value = false
|
||||
previewError.value =
|
||||
err?.name === 'NotAllowedError'
|
||||
? '摄像头权限被拒绝,请在浏览器地址栏允许摄像头访问'
|
||||
: err?.name === 'NotReadableError'
|
||||
? '摄像头被其他程序占用,无法打开预览'
|
||||
: '预览启动失败,请检查摄像头设备'
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stopPreview = () => {
|
||||
if (document.fullscreenElement && document.fullscreenElement === previewStageRef.value) {
|
||||
void document.exitFullscreen().catch(() => {})
|
||||
}
|
||||
if (previewVideoRef.value) previewVideoRef.value.srcObject = null
|
||||
// destroy 会同时停掉输出轨和源摄像头轨,释放摄像头占用
|
||||
previewPipeline?.destroy()
|
||||
previewPipeline = null
|
||||
previewing.value = false
|
||||
previewLoading.value = false
|
||||
}
|
||||
|
||||
const togglePreview = () => {
|
||||
if (previewing.value) {
|
||||
stopPreview()
|
||||
} else {
|
||||
void startPreview()
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
const defaults = defaultBeautySettings()
|
||||
Object.assign(settings.advanced, defaults.advanced, { enabled: settings.advanced.enabled })
|
||||
Object.assign(settings, {
|
||||
style: defaults.style,
|
||||
beautyLevel: defaults.beautyLevel,
|
||||
whitenessLevel: defaults.whitenessLevel,
|
||||
ruddinessLevel: defaults.ruddinessLevel
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (applyTimer) clearTimeout(applyTimer)
|
||||
stopPreview()
|
||||
onDragEnd()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
applyNow,
|
||||
applyIfEnabled,
|
||||
openPanel
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.beauty-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.beauty-btn-text {
|
||||
margin-left: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.beauty-window {
|
||||
position: fixed;
|
||||
max-width: 94vw;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.28);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.beauty-window-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
|
||||
.beauty-window-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.beauty-window-close {
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
color: #ffd4d4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.beauty-window-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
|
||||
@media (max-width: 820px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.beauty-preview-col {
|
||||
flex: 1 1 440px;
|
||||
min-width: 0;
|
||||
|
||||
.beauty-preview-stage {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
aspect-ratio: 4 / 3;
|
||||
|
||||
.beauty-preview-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* 本地自拍习惯镜像显示;实际发送给对方的画面不镜像 */
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.beauty-preview-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: #8a8f99;
|
||||
font-size: 13px;
|
||||
padding: 0 24px;
|
||||
text-align: center;
|
||||
|
||||
.beauty-preview-error-text {
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
|
||||
.beauty-preview-badge {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.beauty-fullscreen-btn {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* 全屏时铺满屏幕 */
|
||||
&:fullscreen {
|
||||
aspect-ratio: auto;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
.beauty-preview-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
|
||||
.beauty-preview-note {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.beauty-control-col {
|
||||
flex: 0 0 280px;
|
||||
min-width: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.beauty-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.beauty-section-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.beauty-help-icon {
|
||||
color: #a8abb2;
|
||||
cursor: help;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.beauty-divider {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.beauty-section-body {
|
||||
&.is-disabled {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.beauty-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.beauty-label {
|
||||
flex-shrink: 0;
|
||||
width: 52px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.el-slider {
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.el-color-picker {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.beauty-unsupported {
|
||||
padding: 4px 0 8px;
|
||||
font-size: 12px;
|
||||
color: #e6a23c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.beauty-restart-hint {
|
||||
margin-top: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: #fdf6ec;
|
||||
font-size: 12px;
|
||||
color: #e6a23c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.beauty-window-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
|
||||
.beauty-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -64,6 +64,20 @@
|
||||
>
|
||||
群视频
|
||||
</el-button>
|
||||
<!-- 美颜设置(免费基础美颜 + 本地 AI 瘦脸/口红/腮红),带大窗口实时预览;仅对开启了美颜功能的医生显示 -->
|
||||
<CallBeautyPanel
|
||||
v-if="isCallReady && beautyAllowed"
|
||||
ref="beautyPanelRef"
|
||||
:get-engine="getCallEngine"
|
||||
:popper-z-index="300002"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button size="small">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span style="margin-left: 2px">美颜</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</CallBeautyPanel>
|
||||
</div>
|
||||
</template>
|
||||
</MessageInput>
|
||||
@@ -127,7 +141,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Loading, Close, Rank, Camera, Fold, Expand } from '@element-plus/icons-vue'
|
||||
import { Loading, Close, Rank, Camera, Fold, Expand, MagicStick } from '@element-plus/icons-vue'
|
||||
import CallBeautyPanel from '@/components/call-beauty-panel/index.vue'
|
||||
import { uploadImageBlob, uploadVideoBlob } from '@/api/file'
|
||||
import { addDoctorNote } from '@/api/patient'
|
||||
import {
|
||||
@@ -187,6 +202,17 @@ const patientUserId = ref('')
|
||||
const conversationId = ref('')
|
||||
const isCallReady = ref(false)
|
||||
const showCallKitWindow = ref(false) // 仅在通话中显示视频窗口
|
||||
/** 美颜面板(本地 AI 管线 + 引擎兜底,非付费特效 SDK) */
|
||||
const beautyPanelRef = ref<InstanceType<typeof CallBeautyPanel> | null>(null)
|
||||
/** 医生编辑页「美颜功能」开关:关闭时不显示美颜入口 */
|
||||
const beautyAllowed = computed(() => userStore.userInfo?.enable_beauty !== 0)
|
||||
const getCallEngine = () => {
|
||||
try {
|
||||
return TUICallKitServer.getTUICallEngineInstance()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const { login, logout } = useLoginState()
|
||||
const { setActiveConversation, createC2CConversation, activeConversation } = useConversationListState()
|
||||
const userStore = useUserStore()
|
||||
@@ -723,6 +749,8 @@ function setupCallRoomBinding() {
|
||||
}
|
||||
if (next === CALL_STATUS_CONNECTED) {
|
||||
void tryBindRoomIfReady()
|
||||
// 接通后重新下发已保存的美颜设置(摄像头此时已开启,插件可正常启动)
|
||||
void beautyPanelRef.value?.applyIfEnabled()
|
||||
clearLocalRecordingStartTimer()
|
||||
if (!isLochostVodEnabled.value) {
|
||||
previousCallStatus = next
|
||||
@@ -1091,6 +1119,10 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
|
||||
showCallKitWindow.value = true
|
||||
pendingCallRecordStart = ensureCallRecordStarted()
|
||||
void pendingCallRecordStart
|
||||
// 呼叫阶段本地摄像头预览已开启,延迟下发美颜让医生在预览中即可看到效果
|
||||
setTimeout(() => {
|
||||
void beautyPanelRef.value?.applyIfEnabled()
|
||||
}, 1500)
|
||||
},
|
||||
// 须先 endCall(DeleteCloudRecording),再 await 本地上传;否则上传 WebM 耗时数分钟会拖住云端停录,控制台房间长期「尚未结束」
|
||||
afterCalling: () => {
|
||||
|
||||
@@ -55,6 +55,20 @@
|
||||
|
||||
<!-- 窗口底部操作栏 -->
|
||||
<div class="window-footer">
|
||||
<!-- 美颜设置(本地 AI 管线,免费);仅对开启了美颜功能的医生显示 -->
|
||||
<CallBeautyPanel
|
||||
v-if="isInitialized && !callRejected && beautyAllowed"
|
||||
ref="beautyPanelRef"
|
||||
:get-engine="getCallEngine"
|
||||
:popper-z-index="2100"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button size="small">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span style="margin-left: 2px">美颜</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</CallBeautyPanel>
|
||||
<el-button
|
||||
@click="handleClose"
|
||||
:disabled="calling && !callRejected"
|
||||
@@ -92,11 +106,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted, nextTick, computed } from 'vue'
|
||||
import { VideoCamera, CircleClose, Phone } from '@element-plus/icons-vue'
|
||||
import { VideoCamera, CircleClose, Phone, MagicStick } from '@element-plus/icons-vue'
|
||||
import { getCallSignature, endCall } from '@/api/tcm'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { formatTUICallUserError } from '@/utils/tuicall-error'
|
||||
import { TUICallKitAPI, TUICallKit, TUICallType, STATUS } from '@trtc/calls-uikit-vue'
|
||||
import CallBeautyPanel from '@/components/call-beauty-panel/index.vue'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
|
||||
interface CallInfo {
|
||||
diagnosisId: number
|
||||
@@ -124,6 +140,18 @@ const callInfo = ref<CallInfo>({
|
||||
isGroup: false
|
||||
})
|
||||
|
||||
/** 美颜面板(本地 AI 管线 + 引擎兜底,非付费特效 SDK) */
|
||||
const beautyPanelRef = ref<InstanceType<typeof CallBeautyPanel> | null>(null)
|
||||
/** 医生编辑页「美颜功能」开关:关闭时不显示美颜入口 */
|
||||
const beautyAllowed = computed(() => useUserStore().userInfo?.enable_beauty !== 0)
|
||||
const getCallEngine = () => {
|
||||
try {
|
||||
return TUICallKitAPI.getTUICallEngineInstance()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口位置和大小
|
||||
const windowRef = ref<HTMLElement>()
|
||||
const windowPosition = ref({ x: 100, y: 100 })
|
||||
@@ -233,6 +261,8 @@ const handleStatusChange = ({ oldStatus, newStatus }: any) => {
|
||||
shouldAutoClose.value = false // 通话接通时,不自动关闭
|
||||
statusText.value = '通话中...'
|
||||
feedback.msgSuccess('通话已接通')
|
||||
// 接通后重新下发已保存的美颜设置(摄像头此时已开启,插件可正常启动)
|
||||
void beautyPanelRef.value?.applyIfEnabled()
|
||||
|
||||
// 如果是多人通话且还有其他用户需要邀请
|
||||
if (callInfo.value.isGroup && callInfo.value.userIds && callInfo.value.userIds.length > 1) {
|
||||
@@ -247,6 +277,10 @@ const handleStatusChange = ({ oldStatus, newStatus }: any) => {
|
||||
calling.value = true
|
||||
shouldAutoClose.value = false
|
||||
statusText.value = '等待对方接听...'
|
||||
// 呼叫阶段本地摄像头预览已开启,延迟下发美颜让医生在预览中即可看到效果
|
||||
setTimeout(() => {
|
||||
void beautyPanelRef.value?.applyIfEnabled()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// 通话结束,回到空闲状态
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* getUserMedia 拦截器:给通话视频流透明注入本地 AI 高级美颜
|
||||
*
|
||||
* TUICallKit / TRTC SDK 申请摄像头时走 navigator.mediaDevices.getUserMedia,
|
||||
* 这里在其外层包一层:拿到真实摄像头流后,若高级美颜已开启,则把视频轨送入
|
||||
* FaceBeautyPipeline 处理,返回处理后的流。SDK 对此无感知,无需改动任何 SDK 代码。
|
||||
*
|
||||
* 仅处理"摄像头视频"请求;纯音频、屏幕共享(getDisplayMedia)不受影响。
|
||||
* 管线创建失败时始终回退原始流,不会因美颜故障导致通话不可用。
|
||||
*/
|
||||
import { hasAnyBeautyEffect, loadBeautySettings } from '@/utils/call-beauty'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import {
|
||||
FaceBeautyPipeline,
|
||||
getFaceLandmarker,
|
||||
isAdvancedBeautySupported
|
||||
} from './face-beauty-pipeline'
|
||||
|
||||
/**
|
||||
* 当前登录医生是否被允许使用美颜(la_admin.enable_beauty,医生编辑页配置)。
|
||||
* 字段缺失(旧数据/旧接口)时视为允许,与后端默认值保持一致。
|
||||
*/
|
||||
export function isBeautyAllowedForCurrentUser(): boolean {
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
return userStore.userInfo?.enable_beauty !== 0
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let installed = false
|
||||
let originalGetUserMedia: ((constraints?: MediaStreamConstraints) => Promise<MediaStream>) | null =
|
||||
null
|
||||
|
||||
/**
|
||||
* 绕过拦截器直接获取原始摄像头流。
|
||||
* 供美颜预览使用:预览自己管理管线,避免被拦截器再包一层造成双重处理。
|
||||
*/
|
||||
export function getRawUserMedia(constraints?: MediaStreamConstraints): Promise<MediaStream> {
|
||||
if (originalGetUserMedia) return originalGetUserMedia(constraints)
|
||||
return navigator.mediaDevices.getUserMedia(constraints)
|
||||
}
|
||||
|
||||
async function wrapStreamWithBeauty(stream: MediaStream): Promise<MediaStream> {
|
||||
const videoTrack = stream.getVideoTracks()[0]
|
||||
if (!videoTrack) return stream
|
||||
|
||||
const settings = loadBeautySettings()
|
||||
const pipeline = new FaceBeautyPipeline(videoTrack, settings)
|
||||
const processedTrack = await pipeline.start()
|
||||
|
||||
// SDK 停用处理后的轨道时,同步销毁管线并释放真实摄像头
|
||||
const originalStop = processedTrack.stop.bind(processedTrack)
|
||||
processedTrack.stop = () => {
|
||||
originalStop()
|
||||
pipeline.destroy()
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
}
|
||||
|
||||
return new MediaStream([processedTrack, ...stream.getAudioTracks()])
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装拦截器(幂等)。需在 SDK 发起摄像头请求前调用——
|
||||
* 目前由美颜面板组件挂载时触发,早于任何通话开始。
|
||||
*/
|
||||
export function installBeautyMediaInterceptor() {
|
||||
if (installed) return
|
||||
if (!navigator.mediaDevices?.getUserMedia) return
|
||||
installed = true
|
||||
|
||||
const original = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices)
|
||||
originalGetUserMedia = original
|
||||
|
||||
navigator.mediaDevices.getUserMedia = async (constraints?: MediaStreamConstraints) => {
|
||||
const stream = await original(constraints)
|
||||
if (!constraints?.video) return stream
|
||||
|
||||
const settings = loadBeautySettings()
|
||||
if (
|
||||
!isBeautyAllowedForCurrentUser() ||
|
||||
!hasAnyBeautyEffect(settings) ||
|
||||
!isAdvancedBeautySupported()
|
||||
) {
|
||||
return stream
|
||||
}
|
||||
|
||||
try {
|
||||
return await wrapStreamWithBeauty(stream)
|
||||
} catch (err) {
|
||||
console.warn('[face-beauty] 美颜管线创建失败,使用原始摄像头画面', err)
|
||||
return stream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 高级美颜开启时提前预热模型(约 4MB,加载一次全局复用),避免首帧卡顿 */
|
||||
export function preloadBeautyModel() {
|
||||
if (!isAdvancedBeautySupported()) return
|
||||
getFaceLandmarker().catch(() => {
|
||||
// 预热失败不打扰用户,实际使用时还会重试并有兜底
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
/**
|
||||
* 本地美颜管线(磨皮/美白/红润/瘦脸/口红/腮红)
|
||||
*
|
||||
* 实现思路(全部免费、不依赖腾讯特效 SDK):
|
||||
* 1. MediaPipe Face Landmarker(Apache 2.0)在浏览器内逐帧输出 478 个人脸关键点;
|
||||
* 2. WebGL 多 pass 渲染:
|
||||
* - 瘦脸:片元着色器局部液化变形,把下颌关键点附近的像素向面部中心推;
|
||||
* - 磨皮:分离高斯模糊 + 亮度差边缘保护混合(保留五官细节,只柔化皮肤);
|
||||
* - 美白/红润:中间调提亮曲线 + 暖色偏移;
|
||||
* 3. 口红/腮红:按唇部/颧骨关键点在 2D Canvas 上着色,用混合模式叠回画面;
|
||||
* 4. 处理结果经 canvas.captureStream() 输出为新的视频轨,由 getUserMedia
|
||||
* 拦截器(beauty-media-interceptor.ts)透明地替换给 TUICallKit。
|
||||
*
|
||||
* 注:基础美颜(磨皮/美白/红润)不再走 TRTC 引擎的 BasicBeauty 插件,统一在
|
||||
* 本管线内处理,保证预览所见即所得;仅在 WebGL 不可用时回退引擎插件。
|
||||
*
|
||||
* 模型与 WASM 优先从本站 /mediapipe/ 加载(随前端部署),失败时回退官方 CDN。
|
||||
*/
|
||||
import {
|
||||
FaceLandmarker,
|
||||
FilesetResolver,
|
||||
type NormalizedLandmark
|
||||
} from '@mediapipe/tasks-vision'
|
||||
import { BEAUTY_STYLE_SMOOTH, type CallBeautySettings } from '@/utils/call-beauty'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MediaPipe Face Landmarker 单例
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MEDIAPIPE_VERSION = '0.10.35'
|
||||
const CDN_WASM_BASE = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_VERSION}/wasm`
|
||||
const CDN_MODEL_URL =
|
||||
'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task'
|
||||
|
||||
const localAssetBase = () => {
|
||||
const base = (import.meta.env.BASE_URL || '/').replace(/\/+$/, '')
|
||||
return `${base}/mediapipe`
|
||||
}
|
||||
|
||||
let landmarkerPromise: Promise<FaceLandmarker> | null = null
|
||||
|
||||
async function createLandmarker(wasmBase: string, modelUrl: string): Promise<FaceLandmarker> {
|
||||
const fileset = await FilesetResolver.forVisionTasks(wasmBase)
|
||||
return FaceLandmarker.createFromOptions(fileset, {
|
||||
baseOptions: {
|
||||
modelAssetPath: modelUrl,
|
||||
delegate: 'GPU'
|
||||
},
|
||||
runningMode: 'VIDEO',
|
||||
numFaces: 1,
|
||||
outputFaceBlendshapes: false,
|
||||
outputFacialTransformationMatrixes: false
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取(懒加载)人脸关键点检测器;本地资源失败时回退 CDN */
|
||||
export function getFaceLandmarker(): Promise<FaceLandmarker> {
|
||||
if (!landmarkerPromise) {
|
||||
const base = localAssetBase()
|
||||
landmarkerPromise = createLandmarker(`${base}/wasm`, `${base}/face_landmarker.task`).catch(
|
||||
(err) => {
|
||||
console.warn('[face-beauty] 本地模型加载失败,回退 CDN', err)
|
||||
return createLandmarker(CDN_WASM_BASE, CDN_MODEL_URL)
|
||||
}
|
||||
)
|
||||
landmarkerPromise.catch(() => {
|
||||
// 两路都失败时允许下次重试
|
||||
landmarkerPromise = null
|
||||
})
|
||||
}
|
||||
return landmarkerPromise
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 关键点索引(MediaPipe Face Mesh 拓扑)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 外唇轮廓(顺时针闭环) */
|
||||
const LIPS_OUTER = [
|
||||
61, 146, 91, 181, 84, 17, 314, 405, 321, 375, 291, 409, 270, 269, 267, 0, 37, 39, 40, 185
|
||||
]
|
||||
/** 内唇轮廓(闭环,用于挖空嘴内区域) */
|
||||
const LIPS_INNER = [
|
||||
78, 95, 88, 178, 87, 14, 317, 402, 318, 324, 308, 415, 310, 311, 312, 13, 82, 81, 80, 191
|
||||
]
|
||||
/** 左/右颧骨中心(腮红位置) */
|
||||
const CHEEK_LEFT = 50
|
||||
const CHEEK_RIGHT = 280
|
||||
/** 面部参考点 */
|
||||
const NOSE_TIP = 4
|
||||
const CHIN = 152
|
||||
const FACE_LEFT = 234
|
||||
const FACE_RIGHT = 454
|
||||
/** 瘦脸拖拽点:左右各 3 个下颌/脸颊关键点 */
|
||||
const JAW_LEFT = [132, 172, 136]
|
||||
const JAW_RIGHT = [361, 397, 365]
|
||||
|
||||
const MAX_WARPS = 6
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebGL 多 pass 渲染器:液化变形(瘦脸)→ 高斯模糊 → 磨皮/美白/红润合成
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 顶点着色器:u_flipY=1 时输出到画布(Y 轴翻转对齐屏幕),=0 时输出到 FBO */
|
||||
const VERTEX_SHADER = `
|
||||
attribute vec2 a_pos;
|
||||
uniform float u_flipY;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
float v = a_pos.y * 0.5;
|
||||
v_uv = vec2(a_pos.x * 0.5 + 0.5, u_flipY > 0.5 ? 0.5 - v : 0.5 + v);
|
||||
gl_Position = vec4(a_pos, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const WARP_FRAGMENT = `
|
||||
precision highp float;
|
||||
uniform sampler2D u_tex;
|
||||
uniform int u_warpCount;
|
||||
uniform vec2 u_warpCenter[${MAX_WARPS}];
|
||||
uniform vec2 u_warpOffset[${MAX_WARPS}];
|
||||
uniform float u_warpRadius[${MAX_WARPS}];
|
||||
uniform vec2 u_aspect;
|
||||
varying vec2 v_uv;
|
||||
|
||||
void main() {
|
||||
vec2 uv = v_uv;
|
||||
for (int i = 0; i < ${MAX_WARPS}; i++) {
|
||||
if (i >= u_warpCount) break;
|
||||
vec2 d = (uv - u_warpCenter[i]) * u_aspect;
|
||||
float dist = length(d);
|
||||
float r = u_warpRadius[i];
|
||||
if (dist < r) {
|
||||
float x = dist / r;
|
||||
float f = (1.0 - x * x);
|
||||
f = f * f;
|
||||
// 反向映射:目标位置向外采样,脸颊边缘被背景"挤"进来,视觉上变窄
|
||||
uv -= u_warpOffset[i] * f;
|
||||
}
|
||||
}
|
||||
gl_FragColor = texture2D(u_tex, clamp(uv, 0.001, 0.999));
|
||||
}
|
||||
`
|
||||
|
||||
const BLUR_FRAGMENT = `
|
||||
precision mediump float;
|
||||
uniform sampler2D u_tex;
|
||||
uniform vec2 u_dir;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
vec4 sum = texture2D(u_tex, v_uv) * 0.227027;
|
||||
sum += (texture2D(u_tex, v_uv + u_dir * 1.3846) + texture2D(u_tex, v_uv - u_dir * 1.3846)) * 0.3162162;
|
||||
sum += (texture2D(u_tex, v_uv + u_dir * 3.2308) + texture2D(u_tex, v_uv - u_dir * 3.2308)) * 0.0702703;
|
||||
gl_FragColor = sum;
|
||||
}
|
||||
`
|
||||
|
||||
const FINAL_FRAGMENT = `
|
||||
precision mediump float;
|
||||
uniform sampler2D u_sharp;
|
||||
uniform sampler2D u_blur;
|
||||
uniform float u_smooth;
|
||||
uniform float u_white;
|
||||
uniform float u_ruddy;
|
||||
uniform float u_edgeKeep;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
vec3 a = texture2D(u_sharp, v_uv).rgb;
|
||||
vec3 b = texture2D(u_blur, v_uv).rgb;
|
||||
// 亮度差大 = 五官/轮廓边缘,磨皮时予以保留
|
||||
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);
|
||||
gl_FragColor = vec4(clamp(c, 0.0, 1.0), 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
export interface BasicRenderParams {
|
||||
/** 磨皮 0-1 */
|
||||
smooth: number
|
||||
/** 美白 0-1 */
|
||||
white: number
|
||||
/** 红润 0-1 */
|
||||
ruddy: number
|
||||
/** 边缘保护系数:自然=1.0 保留更多细节,光滑=0.55 更平滑 */
|
||||
edgeKeep: number
|
||||
}
|
||||
|
||||
interface ProgramInfo {
|
||||
program: WebGLProgram
|
||||
aPos: number
|
||||
flipY: WebGLUniformLocation | null
|
||||
uniforms: Record<string, WebGLUniformLocation | null>
|
||||
}
|
||||
|
||||
interface FboInfo {
|
||||
fb: WebGLFramebuffer
|
||||
tex: WebGLTexture
|
||||
}
|
||||
|
||||
class BeautyRenderer {
|
||||
readonly canvas: HTMLCanvasElement
|
||||
private gl: WebGLRenderingContext
|
||||
private videoTexture: WebGLTexture
|
||||
private warpProg: ProgramInfo
|
||||
private blurProg: ProgramInfo
|
||||
private finalProg: ProgramInfo
|
||||
private fboA: FboInfo
|
||||
private fboB: FboInfo
|
||||
private fboC: FboInfo
|
||||
private lost = false
|
||||
|
||||
constructor(width: number, height: number) {
|
||||
this.canvas = document.createElement('canvas')
|
||||
this.canvas.width = width
|
||||
this.canvas.height = height
|
||||
const gl = this.canvas.getContext('webgl', {
|
||||
premultipliedAlpha: false,
|
||||
preserveDrawingBuffer: false
|
||||
})
|
||||
if (!gl) throw new Error('WebGL 不可用')
|
||||
this.gl = gl
|
||||
this.canvas.addEventListener('webglcontextlost', () => {
|
||||
this.lost = true
|
||||
})
|
||||
|
||||
const quad = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1])
|
||||
const buffer = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW)
|
||||
|
||||
this.warpProg = this.buildProgram(WARP_FRAGMENT, [
|
||||
'u_tex',
|
||||
'u_warpCount',
|
||||
'u_warpCenter',
|
||||
'u_warpOffset',
|
||||
'u_warpRadius',
|
||||
'u_aspect'
|
||||
])
|
||||
this.blurProg = this.buildProgram(BLUR_FRAGMENT, ['u_tex', 'u_dir'])
|
||||
this.finalProg = this.buildProgram(FINAL_FRAGMENT, [
|
||||
'u_sharp',
|
||||
'u_blur',
|
||||
'u_smooth',
|
||||
'u_white',
|
||||
'u_ruddy',
|
||||
'u_edgeKeep'
|
||||
])
|
||||
|
||||
this.videoTexture = this.createTexture()
|
||||
this.fboA = this.createFbo(width, height)
|
||||
this.fboB = this.createFbo(width, height)
|
||||
this.fboC = this.createFbo(width, height)
|
||||
}
|
||||
|
||||
get isLost() {
|
||||
return this.lost
|
||||
}
|
||||
|
||||
private compile(type: number, source: string): WebGLShader {
|
||||
const gl = this.gl
|
||||
const shader = gl.createShader(type)!
|
||||
gl.shaderSource(shader, source)
|
||||
gl.compileShader(shader)
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
throw new Error(`着色器编译失败: ${gl.getShaderInfoLog(shader)}`)
|
||||
}
|
||||
return shader
|
||||
}
|
||||
|
||||
private buildProgram(fragmentSrc: string, uniformNames: string[]): ProgramInfo {
|
||||
const gl = this.gl
|
||||
const program = gl.createProgram()!
|
||||
gl.attachShader(program, this.compile(gl.VERTEX_SHADER, VERTEX_SHADER))
|
||||
gl.attachShader(program, this.compile(gl.FRAGMENT_SHADER, fragmentSrc))
|
||||
gl.linkProgram(program)
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
throw new Error(`着色器链接失败: ${gl.getProgramInfoLog(program)}`)
|
||||
}
|
||||
const uniforms: Record<string, WebGLUniformLocation | null> = {}
|
||||
for (const name of uniformNames) {
|
||||
uniforms[name] = gl.getUniformLocation(program, name)
|
||||
}
|
||||
return {
|
||||
program,
|
||||
aPos: gl.getAttribLocation(program, 'a_pos'),
|
||||
flipY: gl.getUniformLocation(program, 'u_flipY'),
|
||||
uniforms
|
||||
}
|
||||
}
|
||||
|
||||
private createTexture(): WebGLTexture {
|
||||
const gl = this.gl
|
||||
const tex = gl.createTexture()!
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
return tex
|
||||
}
|
||||
|
||||
private createFbo(width: number, height: number): FboInfo {
|
||||
const gl = this.gl
|
||||
const tex = this.createTexture()
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
|
||||
const fb = gl.createFramebuffer()!
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fb)
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
|
||||
return { fb, tex }
|
||||
}
|
||||
|
||||
private usePass(prog: ProgramInfo, target: WebGLFramebuffer | null, flipY: boolean) {
|
||||
const gl = this.gl
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, target)
|
||||
gl.viewport(0, 0, this.canvas.width, this.canvas.height)
|
||||
gl.useProgram(prog.program)
|
||||
gl.enableVertexAttribArray(prog.aPos)
|
||||
gl.vertexAttribPointer(prog.aPos, 2, gl.FLOAT, false, 0, 0)
|
||||
gl.uniform1f(prog.flipY, flipY ? 1 : 0)
|
||||
}
|
||||
|
||||
private bindTex(unit: number, tex: WebGLTexture, location: WebGLUniformLocation | null) {
|
||||
const gl = this.gl
|
||||
gl.activeTexture(gl.TEXTURE0 + unit)
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex)
|
||||
gl.uniform1i(location, unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param warps 每项 [centerX, centerY, offsetX, offsetY, radius](归一化 uv 坐标)
|
||||
* @param basic 磨皮/美白/红润参数(0-1)
|
||||
*/
|
||||
render(video: HTMLVideoElement, warps: number[][], basic: BasicRenderParams) {
|
||||
const gl = this.gl
|
||||
const { width, height } = this.canvas
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0)
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.videoTexture)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video)
|
||||
|
||||
// Pass 1: 液化变形(无变形点时等效透传)→ fboA
|
||||
const wp = this.warpProg
|
||||
this.usePass(wp, this.fboA.fb, false)
|
||||
this.bindTex(0, this.videoTexture, wp.uniforms.u_tex)
|
||||
const count = Math.min(warps.length, MAX_WARPS)
|
||||
const centers = new Float32Array(MAX_WARPS * 2)
|
||||
const offsets = new Float32Array(MAX_WARPS * 2)
|
||||
const radii = new Float32Array(MAX_WARPS)
|
||||
for (let i = 0; i < count; i++) {
|
||||
centers[i * 2] = warps[i][0]
|
||||
centers[i * 2 + 1] = warps[i][1]
|
||||
offsets[i * 2] = warps[i][2]
|
||||
offsets[i * 2 + 1] = warps[i][3]
|
||||
radii[i] = warps[i][4]
|
||||
}
|
||||
gl.uniform1i(wp.uniforms.u_warpCount, count)
|
||||
gl.uniform2fv(wp.uniforms.u_warpCenter, centers)
|
||||
gl.uniform2fv(wp.uniforms.u_warpOffset, offsets)
|
||||
gl.uniform1fv(wp.uniforms.u_warpRadius, radii)
|
||||
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)
|
||||
|
||||
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)
|
||||
blurTex = this.fboC.tex
|
||||
}
|
||||
|
||||
// Pass 4: 磨皮混合 + 美白 + 红润 → 画布
|
||||
const fp = this.finalProg
|
||||
this.usePass(fp, null, true)
|
||||
this.bindTex(0, this.fboA.tex, fp.uniforms.u_sharp)
|
||||
this.bindTex(1, blurTex, fp.uniforms.u_blur)
|
||||
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_edgeKeep, basic.edgeKeep)
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
const ext = this.gl.getExtension('WEBGL_lose_context')
|
||||
ext?.loseContext()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 美颜管线
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 环境能力检测:WebGL + canvas.captureStream 缺一不可 */
|
||||
export function isAdvancedBeautySupported(): boolean {
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
if (typeof canvas.captureStream !== 'function') return false
|
||||
return !!canvas.getContext('webgl')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const activePipelines = new Set<FaceBeautyPipeline>()
|
||||
|
||||
/** 面板调参时对所有活跃管线实时生效 */
|
||||
export function updateActiveBeautyPipelines(settings: CallBeautySettings) {
|
||||
activePipelines.forEach((p) => p.updateSettings(settings))
|
||||
}
|
||||
|
||||
/** 是否存在接管了通话视频流的管线(不含面板预览用的管线) */
|
||||
export function hasActiveBeautyPipeline(): boolean {
|
||||
for (const p of activePipelines) {
|
||||
if (!p.isPreview) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export class FaceBeautyPipeline {
|
||||
/** 面板预览用的管线,不计入"通话流已接管"判断 */
|
||||
readonly isPreview: boolean
|
||||
private sourceTrack: MediaStreamTrack
|
||||
private settings: CallBeautySettings
|
||||
private video: HTMLVideoElement
|
||||
private output: HTMLCanvasElement
|
||||
private ctx: CanvasRenderingContext2D
|
||||
private renderer: BeautyRenderer | null = null
|
||||
private landmarker: FaceLandmarker | null = null
|
||||
private outputStream: MediaStream | null = null
|
||||
private destroyed = false
|
||||
private rafId = 0
|
||||
private vfcId = 0
|
||||
private watchdogTimer: ReturnType<typeof setInterval> | null = null
|
||||
private lastFrameAt = 0
|
||||
private lastDetectTs = -1
|
||||
|
||||
constructor(
|
||||
sourceTrack: MediaStreamTrack,
|
||||
settings: CallBeautySettings,
|
||||
options?: { isPreview?: boolean }
|
||||
) {
|
||||
this.isPreview = Boolean(options?.isPreview)
|
||||
this.sourceTrack = sourceTrack
|
||||
this.settings = this.cloneSettings(settings)
|
||||
this.video = document.createElement('video')
|
||||
this.video.muted = true
|
||||
this.video.playsInline = true
|
||||
this.output = document.createElement('canvas')
|
||||
const ctx = this.output.getContext('2d')
|
||||
if (!ctx) throw new Error('Canvas 2D 不可用')
|
||||
this.ctx = ctx
|
||||
}
|
||||
|
||||
private cloneSettings(settings: CallBeautySettings): CallBeautySettings {
|
||||
return { ...settings, advanced: { ...settings.advanced } }
|
||||
}
|
||||
|
||||
updateSettings(settings: CallBeautySettings) {
|
||||
this.settings = this.cloneSettings(settings)
|
||||
}
|
||||
|
||||
async start(): Promise<MediaStreamTrack> {
|
||||
this.video.srcObject = new MediaStream([this.sourceTrack])
|
||||
await this.video.play()
|
||||
if (this.video.videoWidth === 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.video.addEventListener('loadedmetadata', () => resolve(), { once: true })
|
||||
setTimeout(resolve, 1500)
|
||||
})
|
||||
}
|
||||
const width = this.video.videoWidth || 640
|
||||
const height = this.video.videoHeight || 480
|
||||
this.output.width = width
|
||||
this.output.height = height
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// 先画一帧,保证 captureStream 立即有内容
|
||||
this.drawFrame()
|
||||
this.outputStream = this.output.captureStream(30)
|
||||
const track = this.outputStream.getVideoTracks()[0]
|
||||
|
||||
this.startFrameLoop()
|
||||
this.sourceTrack.addEventListener('ended', () => this.destroy())
|
||||
|
||||
activePipelines.add(this)
|
||||
return track
|
||||
}
|
||||
|
||||
private startFrameLoop() {
|
||||
const anyVideo = this.video as any
|
||||
if (typeof anyVideo.requestVideoFrameCallback === 'function') {
|
||||
const onFrame = () => {
|
||||
if (this.destroyed) return
|
||||
this.drawFrame()
|
||||
this.vfcId = anyVideo.requestVideoFrameCallback(onFrame)
|
||||
}
|
||||
this.vfcId = anyVideo.requestVideoFrameCallback(onFrame)
|
||||
} else {
|
||||
const onRaf = () => {
|
||||
if (this.destroyed) return
|
||||
this.drawFrame()
|
||||
this.rafId = requestAnimationFrame(onRaf)
|
||||
}
|
||||
this.rafId = requestAnimationFrame(onRaf)
|
||||
}
|
||||
// 页签后台时 rVFC/rAF 会停摆,用低频兜底保持输出流不冻结
|
||||
this.watchdogTimer = setInterval(() => {
|
||||
if (this.destroyed) return
|
||||
if (performance.now() - this.lastFrameAt > 400) {
|
||||
this.drawFrame()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
private detect(): NormalizedLandmark[] | null {
|
||||
if (!this.landmarker) return null
|
||||
let ts = performance.now()
|
||||
if (ts <= this.lastDetectTs) ts = this.lastDetectTs + 0.01
|
||||
this.lastDetectTs = ts
|
||||
try {
|
||||
const result = this.landmarker.detectForVideo(this.video, ts)
|
||||
const face = result.faceLandmarks?.[0]
|
||||
return face && face.length > 0 ? face : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private drawFrame() {
|
||||
if (this.destroyed || this.video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return
|
||||
this.lastFrameAt = performance.now()
|
||||
|
||||
const s = this.settings
|
||||
const adv = s.advanced
|
||||
const basic: BasicRenderParams = {
|
||||
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,
|
||||
edgeKeep: s.style === BEAUTY_STYLE_SMOOTH ? 0.55 : 1.0
|
||||
}
|
||||
const wantBasic = basic.smooth > 0 || basic.white > 0 || basic.ruddy > 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 { width, height } = this.output
|
||||
|
||||
// 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
|
||||
)
|
||||
this.ctx.drawImage(this.renderer!.canvas, 0, 0, width, height)
|
||||
} else {
|
||||
this.ctx.drawImage(this.video, 0, 0, width, height)
|
||||
}
|
||||
|
||||
// 2) 美妆(2D 混合)
|
||||
if (landmarks && wantMakeup) {
|
||||
if (adv.lipstickStrength > 0) this.drawLipstick(landmarks)
|
||||
if (adv.blushStrength > 0) this.drawBlush(landmarks)
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成瘦脸变形参数:左右下颌点向"鼻尖-下巴中点"方向收拢 */
|
||||
private buildWarps(lm: NormalizedLandmark[]): number[][] {
|
||||
const { width, height } = this.output
|
||||
const aspect = width / height
|
||||
const faceWidth = Math.hypot(
|
||||
(lm[FACE_RIGHT].x - lm[FACE_LEFT].x) * aspect,
|
||||
lm[FACE_RIGHT].y - lm[FACE_LEFT].y
|
||||
)
|
||||
const target = {
|
||||
x: (lm[NOSE_TIP].x + lm[CHIN].x) / 2,
|
||||
y: (lm[NOSE_TIP].y + lm[CHIN].y) / 2
|
||||
}
|
||||
const strength = (this.settings.advanced.slimStrength / 100) * 0.16
|
||||
const radius = faceWidth * 0.42
|
||||
|
||||
const warps: number[][] = []
|
||||
for (const idx of [...JAW_LEFT, ...JAW_RIGHT]) {
|
||||
const p = lm[idx]
|
||||
const dirX = target.x - p.x
|
||||
const dirY = target.y - p.y
|
||||
const len = Math.hypot(dirX * aspect, dirY) || 1
|
||||
// 位移与脸宽成比例,方向指向面部中心
|
||||
const scale = (faceWidth * strength) / len
|
||||
warps.push([p.x, p.y, dirX * scale, dirY * scale, radius])
|
||||
}
|
||||
return warps
|
||||
}
|
||||
|
||||
private tracePath(lm: NormalizedLandmark[], indices: number[]) {
|
||||
const { width, height } = this.output
|
||||
this.ctx.moveTo(lm[indices[0]].x * width, lm[indices[0]].y * height)
|
||||
for (let i = 1; i < indices.length; i++) {
|
||||
this.ctx.lineTo(lm[indices[i]].x * width, lm[indices[i]].y * height)
|
||||
}
|
||||
this.ctx.closePath()
|
||||
}
|
||||
|
||||
private drawLipstick(lm: NormalizedLandmark[]) {
|
||||
const adv = this.settings.advanced
|
||||
const ctx = this.ctx
|
||||
ctx.save()
|
||||
ctx.globalCompositeOperation = 'multiply'
|
||||
ctx.globalAlpha = Math.min(0.75, (adv.lipstickStrength / 100) * 0.75)
|
||||
ctx.filter = 'blur(2px)'
|
||||
ctx.fillStyle = adv.lipstickColor
|
||||
ctx.beginPath()
|
||||
this.tracePath(lm, LIPS_OUTER)
|
||||
this.tracePath(lm, LIPS_INNER)
|
||||
ctx.fill('evenodd')
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
private drawBlush(lm: NormalizedLandmark[]) {
|
||||
const adv = this.settings.advanced
|
||||
const ctx = this.ctx
|
||||
const { width, height } = this.output
|
||||
const aspect = width / height
|
||||
const faceWidth =
|
||||
Math.hypot((lm[FACE_RIGHT].x - lm[FACE_LEFT].x) * aspect, lm[FACE_RIGHT].y - lm[FACE_LEFT].y) *
|
||||
height
|
||||
const radius = Math.max(8, faceWidth * 0.16)
|
||||
|
||||
ctx.save()
|
||||
ctx.globalCompositeOperation = 'soft-light'
|
||||
ctx.globalAlpha = Math.min(0.9, (adv.blushStrength / 100) * 0.9)
|
||||
for (const idx of [CHEEK_LEFT, CHEEK_RIGHT]) {
|
||||
const cx = lm[idx].x * width
|
||||
const cy = lm[idx].y * height
|
||||
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius)
|
||||
gradient.addColorStop(0, adv.blushColor)
|
||||
gradient.addColorStop(1, `${adv.blushColor}00`)
|
||||
ctx.fillStyle = gradient
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.destroyed) return
|
||||
this.destroyed = true
|
||||
activePipelines.delete(this)
|
||||
|
||||
const anyVideo = this.video as any
|
||||
if (this.vfcId && typeof anyVideo.cancelVideoFrameCallback === 'function') {
|
||||
anyVideo.cancelVideoFrameCallback(this.vfcId)
|
||||
}
|
||||
if (this.rafId) cancelAnimationFrame(this.rafId)
|
||||
if (this.watchdogTimer) clearInterval(this.watchdogTimer)
|
||||
|
||||
this.outputStream?.getTracks().forEach((t) => t.stop())
|
||||
try {
|
||||
this.sourceTrack.stop()
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
this.video.srcObject = null
|
||||
this.renderer?.destroy()
|
||||
this.renderer = null
|
||||
// landmarker 为全局单例,供下次通话复用,不在此销毁
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 视频通话美颜设置
|
||||
*
|
||||
* 全部效果(磨皮/美白/红润/瘦脸/口红/腮红)默认由本地管线处理:
|
||||
* MediaPipe Face Landmarker(开源免费)识别人脸关键点 + WebGL/Canvas 逐帧渲染,
|
||||
* 经 getUserMedia 拦截注入通话视频流(见 utils/beauty/ 目录),预览所见即所得。
|
||||
*
|
||||
* 仅当浏览器不支持 WebGL 时,磨皮/美白/红润回退到腾讯云 TRTC 免费 BasicBeauty
|
||||
* 插件(TUICallEngine.setBeautyLevel)。不依赖付费的腾讯特效 SDK。
|
||||
*/
|
||||
|
||||
/** 高级美颜(本地 AI 处理)设置 */
|
||||
export interface AdvancedBeautySettings {
|
||||
/** 总开关;通话中途开启需重新开关摄像头才能接管视频流 */
|
||||
enabled: boolean
|
||||
/** 瘦脸强度 0-100 */
|
||||
slimStrength: number
|
||||
/** 口红浓度 0-100(0 为关闭) */
|
||||
lipstickStrength: number
|
||||
/** 口红颜色(hex) */
|
||||
lipstickColor: string
|
||||
/** 腮红浓度 0-100(0 为关闭) */
|
||||
blushStrength: number
|
||||
/** 腮红颜色(hex) */
|
||||
blushColor: string
|
||||
}
|
||||
|
||||
export interface CallBeautySettings {
|
||||
/** 是否启用基础美颜(关闭时以全 0 参数下发,引擎会停用 BasicBeauty 插件) */
|
||||
enabled: boolean
|
||||
/** 磨皮算法:0 光滑(TRTCBeautyStyleSmooth)/ 1 自然(TRTCBeautyStyleNature) */
|
||||
style: number
|
||||
/** 磨皮级别 0-9 */
|
||||
beautyLevel: number
|
||||
/** 美白级别 0-9 */
|
||||
whitenessLevel: number
|
||||
/** 红润级别 0-9 */
|
||||
ruddinessLevel: number
|
||||
/** 高级美颜(瘦脸/口红/腮红,本地 AI) */
|
||||
advanced: AdvancedBeautySettings
|
||||
}
|
||||
|
||||
export const BEAUTY_STYLE_SMOOTH = 0
|
||||
export const BEAUTY_STYLE_NATURE = 1
|
||||
export const BEAUTY_LEVEL_MAX = 9
|
||||
|
||||
const STORAGE_KEY = 'tcm_call_beauty_settings'
|
||||
|
||||
export const defaultAdvancedBeautySettings = (): AdvancedBeautySettings => ({
|
||||
enabled: false,
|
||||
slimStrength: 30,
|
||||
lipstickStrength: 0,
|
||||
lipstickColor: '#c94f5e',
|
||||
blushStrength: 0,
|
||||
blushColor: '#e88193'
|
||||
})
|
||||
|
||||
export const defaultBeautySettings = (): CallBeautySettings => ({
|
||||
enabled: false,
|
||||
style: BEAUTY_STYLE_NATURE,
|
||||
beautyLevel: 5,
|
||||
whitenessLevel: 3,
|
||||
ruddinessLevel: 2,
|
||||
advanced: defaultAdvancedBeautySettings()
|
||||
})
|
||||
|
||||
const clampLevel = (value: unknown): number => {
|
||||
const num = Math.round(Number(value))
|
||||
if (!Number.isFinite(num)) return 0
|
||||
return Math.min(BEAUTY_LEVEL_MAX, Math.max(0, num))
|
||||
}
|
||||
|
||||
const clampPercent = (value: unknown, fallback: number): number => {
|
||||
const num = Math.round(Number(value))
|
||||
if (!Number.isFinite(num)) return fallback
|
||||
return Math.min(100, Math.max(0, num))
|
||||
}
|
||||
|
||||
const normalizeHexColor = (value: unknown, fallback: string): string => {
|
||||
if (typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value)) return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** 高级美颜是否有任一效果实际开启 */
|
||||
export function hasAnyAdvancedEffect(advanced: AdvancedBeautySettings): boolean {
|
||||
return (
|
||||
advanced.enabled &&
|
||||
(advanced.slimStrength > 0 || advanced.lipstickStrength > 0 || advanced.blushStrength > 0)
|
||||
)
|
||||
}
|
||||
|
||||
/** 全部美颜(基础 + 高级)是否有任一效果实际开启(决定是否接管摄像头流) */
|
||||
export function hasAnyBeautyEffect(settings: CallBeautySettings): boolean {
|
||||
const basicOn =
|
||||
settings.enabled &&
|
||||
(settings.beautyLevel > 0 || settings.whitenessLevel > 0 || settings.ruddinessLevel > 0)
|
||||
return basicOn || hasAnyAdvancedEffect(settings.advanced)
|
||||
}
|
||||
|
||||
export function loadBeautySettings(): CallBeautySettings {
|
||||
const defaults = defaultBeautySettings()
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return defaults
|
||||
const parsed = JSON.parse(raw)
|
||||
const advDefaults = defaults.advanced
|
||||
const adv = parsed?.advanced ?? {}
|
||||
return {
|
||||
enabled: Boolean(parsed?.enabled),
|
||||
style:
|
||||
parsed?.style === BEAUTY_STYLE_SMOOTH
|
||||
? BEAUTY_STYLE_SMOOTH
|
||||
: BEAUTY_STYLE_NATURE,
|
||||
beautyLevel: clampLevel(parsed?.beautyLevel ?? defaults.beautyLevel),
|
||||
whitenessLevel: clampLevel(parsed?.whitenessLevel ?? defaults.whitenessLevel),
|
||||
ruddinessLevel: clampLevel(parsed?.ruddinessLevel ?? defaults.ruddinessLevel),
|
||||
advanced: {
|
||||
enabled: Boolean(adv?.enabled),
|
||||
slimStrength: clampPercent(adv?.slimStrength, advDefaults.slimStrength),
|
||||
lipstickStrength: clampPercent(adv?.lipstickStrength, advDefaults.lipstickStrength),
|
||||
lipstickColor: normalizeHexColor(adv?.lipstickColor, advDefaults.lipstickColor),
|
||||
blushStrength: clampPercent(adv?.blushStrength, advDefaults.blushStrength),
|
||||
blushColor: normalizeHexColor(adv?.blushColor, advDefaults.blushColor)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
export function saveBeautySettings(settings: CallBeautySettings) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
||||
} catch {
|
||||
// localStorage 不可用时静默忽略,仅本次会话生效
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将美颜设置应用到 TUICallEngine 实例。
|
||||
* 引擎内部对 setBeautyLevel 自带 try-catch(摄像头未开启等场景不会抛错),
|
||||
* 这里只兜底引擎不存在 / 接口缺失的情况。
|
||||
*/
|
||||
export async function applyBeautyToEngine(
|
||||
engine: any,
|
||||
settings: CallBeautySettings
|
||||
): Promise<boolean> {
|
||||
if (!engine || typeof engine.setBeautyLevel !== 'function') {
|
||||
return false
|
||||
}
|
||||
const enabled = settings.enabled
|
||||
try {
|
||||
await engine.setBeautyLevel({
|
||||
style: settings.style === BEAUTY_STYLE_SMOOTH ? BEAUTY_STYLE_SMOOTH : BEAUTY_STYLE_NATURE,
|
||||
beautyLevel: enabled ? clampLevel(settings.beautyLevel) : 0,
|
||||
whitenessLevel: enabled ? clampLevel(settings.whitenessLevel) : 0,
|
||||
ruddinessLevel: enabled ? clampLevel(settings.ruddinessLevel) : 0
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('[call-beauty] 应用美颜设置失败', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,19 @@
|
||||
<span class="ml-2 text-gray-500">{{ formData.enable_charge === 1 ? '已开启' : '已关闭' }}</span>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 美颜功能 -->
|
||||
<el-form-item label="美颜功能">
|
||||
<div>
|
||||
<el-switch
|
||||
v-model="formData.enable_beauty"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
/>
|
||||
<span class="ml-2 text-gray-500">{{ formData.enable_beauty === 1 ? '已开启' : '已关闭' }}</span>
|
||||
<div class="form-tips">开启后该医生视频通话时可配置美颜(磨皮/美白/瘦脸/口红等);关闭则不显示美颜入口</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 所属科室 -->
|
||||
<el-form-item label="所属科室" prop="department">
|
||||
<el-select v-model="formData.department" placeholder="请选择所属科室" clearable class="flex-1">
|
||||
@@ -295,6 +308,7 @@ const formData = reactive({
|
||||
enable_image_consult: 1, // 是否开启图文问诊
|
||||
enable_video_consult: 1, // 是否开启视频问诊
|
||||
enable_charge: 0, // 是否开启收费
|
||||
enable_beauty: 1, // 是否开启美颜功能
|
||||
department: '', // 所属科室
|
||||
specialty: '', // 擅长领域
|
||||
education: '', // 教育背景
|
||||
@@ -436,6 +450,7 @@ const open = (type = 'add') => {
|
||||
enable_image_consult: 1,
|
||||
enable_video_consult: 1,
|
||||
enable_charge: 0,
|
||||
enable_beauty: 1,
|
||||
department: '',
|
||||
specialty: '',
|
||||
education: '',
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -86,6 +86,7 @@ class AdminLogic extends BaseLogic
|
||||
'enable_image_consult' => $params['enable_image_consult'] ?? 1,
|
||||
'enable_video_consult' => $params['enable_video_consult'] ?? 1,
|
||||
'enable_charge' => $params['enable_charge'] ?? 0,
|
||||
'enable_beauty' => $params['enable_beauty'] ?? 1,
|
||||
]);
|
||||
|
||||
// 角色
|
||||
@@ -151,6 +152,7 @@ class AdminLogic extends BaseLogic
|
||||
'enable_image_consult' => $params['enable_image_consult'] ?? 1,
|
||||
'enable_video_consult' => $params['enable_video_consult'] ?? 1,
|
||||
'enable_charge' => $params['enable_charge'] ?? 0,
|
||||
'enable_beauty' => $params['enable_beauty'] ?? 1,
|
||||
];
|
||||
|
||||
// 头像
|
||||
@@ -286,7 +288,7 @@ class AdminLogic extends BaseLogic
|
||||
'gender', 'age', 'phone', 'title', 'department',
|
||||
'specialty', 'education', 'experience', 'honors',
|
||||
'license_no', 'qualification_images', 'enable_image_consult', 'enable_video_consult', 'enable_charge',
|
||||
'work_wechat_userid'
|
||||
'enable_beauty', 'work_wechat_userid'
|
||||
])->findOrEmpty($params['id'])->toArray();
|
||||
|
||||
// 将资质图片JSON字符串转换为数组,供前端组件使用
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 医生表(la_admin)新增字段:是否开启美颜功能
|
||||
-- 开启后医生端(管理后台通话)才显示美颜设置入口;关闭则不显示
|
||||
-- 若项目使用 zyt_ 前缀,请将 la_admin 替换为 zyt_admin 后执行
|
||||
|
||||
ALTER TABLE `la_admin` ADD COLUMN `enable_beauty` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否开启美颜功能:0-关闭 1-开启';
|
||||
Reference in New Issue
Block a user