新增美颜

This commit is contained in:
Your Name
2026-07-03 16:51:02 +08:00
parent eb0efba739
commit 1777fe7d8b
17 changed files with 19413 additions and 3 deletions
+7
View File
@@ -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",
+1
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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
/**
* 基础美颜下发到 TUICallEngineTRTC 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>
+33 -1
View File
@@ -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)
},
// 须先 endCallDeleteCloudRecording),再 await 本地上传;否则上传 WebM 耗时数分钟会拖住云端停录,控制台房间长期「尚未结束」
afterCalling: () => {
+35 -1
View File
@@ -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 LandmarkerApache 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 为全局单例,供下次通话复用,不在此销毁
}
}
+164
View File
@@ -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-1000 为关闭) */
lipstickStrength: number
/** 口红颜色(hex */
lipstickColor: string
/** 腮红浓度 0-1000 为关闭) */
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
}
}
+15
View File
@@ -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-开启';