fix: PNG face avatar + whisper ASR
This commit is contained in:
@@ -2,24 +2,8 @@
|
||||
<view class="digital-human">
|
||||
<view class="avatar-stage">
|
||||
<view class="avatar-ring" :class="{ speaking: isSpeaking }">
|
||||
<view class="avatar-css">
|
||||
<view class="face-css">
|
||||
<view class="face-skin">
|
||||
<view class="eye eye-left" :class="{ blink: isBlinking }"></view>
|
||||
<view class="eye eye-right" :class="{ blink: isBlinking }"></view>
|
||||
<view class="eyebrow eyebrow-left"></view>
|
||||
<view class="eyebrow eyebrow-right"></view>
|
||||
<view class="nose"></view>
|
||||
<view class="mouth" :class="{ speaking: isSpeaking }">
|
||||
<view class="mouth-inner" :style="mouthStyle"></view>
|
||||
</view>
|
||||
<view class="blush blush-left"></view>
|
||||
<view class="blush blush-right"></view>
|
||||
</view>
|
||||
<view class="hair"></view>
|
||||
</view>
|
||||
</view>
|
||||
<canvas v-if="isH5" id="dh-mouth" class="mouth-canvas"></canvas>
|
||||
<image class="face-img" src="/static/ai-face.png" mode="aspectFill" />
|
||||
<view class="mouth-overlay" :class="{ open: isSpeaking }" :style="{ height: mouthHeight + 'rpx' }"></view>
|
||||
</view>
|
||||
<view class="status-dot" :class="{ active: isSpeaking }"></view>
|
||||
<text class="role-label">AI 面试官</text>
|
||||
@@ -33,7 +17,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
text: { type: String, default: '' },
|
||||
@@ -44,30 +28,21 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['speaking-start', 'speaking-end'])
|
||||
|
||||
const isH5 = ref(false)
|
||||
const isSpeaking = ref(false)
|
||||
const isBlinking = ref(false)
|
||||
const currentText = ref('')
|
||||
const mouthOpenRatio = ref(0)
|
||||
const mouthHeight = ref(4)
|
||||
|
||||
let audioEl = null
|
||||
let mouthTimer = null
|
||||
let blinkTimer = null
|
||||
let mouthAnimTimer = null
|
||||
|
||||
const mouthStyle = computed(() => ({
|
||||
height: (2 + mouthOpenRatio.value * 14) + 'rpx',
|
||||
width: (12 + mouthOpenRatio.value * 8) + 'rpx',
|
||||
}))
|
||||
|
||||
onMounted(() => {
|
||||
isH5.value = typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||
scheduleBlink()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopAudio()
|
||||
if (blinkTimer) clearTimeout(blinkTimer)
|
||||
if (mouthAnimTimer) clearInterval(mouthAnimTimer)
|
||||
})
|
||||
|
||||
watch(() => props.audioUrl, (url) => {
|
||||
@@ -81,10 +56,12 @@ watch(() => props.text, (txt) => {
|
||||
})
|
||||
|
||||
function scheduleBlink() {
|
||||
const delay = 2000 + Math.random() * 3000
|
||||
const delay = 2500 + Math.random() * 3500
|
||||
blinkTimer = setTimeout(() => {
|
||||
isBlinking.value = true
|
||||
setTimeout(() => { isBlinking.value = false }, 150)
|
||||
if (isSpeaking.value) {
|
||||
mouthHeight.value = 14
|
||||
setTimeout(() => { mouthHeight.value = 4 }, 100)
|
||||
}
|
||||
scheduleBlink()
|
||||
}, delay)
|
||||
}
|
||||
@@ -109,19 +86,20 @@ function playAudio(url) {
|
||||
}
|
||||
|
||||
function startMouthAnim() {
|
||||
let direction = 1
|
||||
mouthAnimTimer = setInterval(() => {
|
||||
const step = mouthOpenRatio.value + direction * 0.08
|
||||
if (step >= 0.8) direction = -1
|
||||
else if (step <= 0.1) direction = 1
|
||||
mouthOpenRatio.value = Math.max(0.05, Math.min(1, step))
|
||||
}, 80)
|
||||
let dir = 1
|
||||
let h = 4
|
||||
mouthTimer = setInterval(() => {
|
||||
h += dir * 2
|
||||
if (h >= 16) dir = -1
|
||||
else if (h <= 4) dir = 1
|
||||
mouthHeight.value = h
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function finishSpeaking() {
|
||||
isSpeaking.value = false
|
||||
mouthOpenRatio.value = 0
|
||||
if (mouthAnimTimer) { clearInterval(mouthAnimTimer); mouthAnimTimer = null }
|
||||
mouthHeight.value = 4
|
||||
if (mouthTimer) { clearInterval(mouthTimer); mouthTimer = null }
|
||||
cleanupAudio()
|
||||
emit('speaking-end')
|
||||
}
|
||||
@@ -167,154 +145,29 @@ defineExpose({ play: playAudio, stop: stopAudio })
|
||||
.avatar-ring.speaking {
|
||||
border-color: #6366F1;
|
||||
box-shadow: 0 0 40rpx rgba(99, 102, 241, 0.4);
|
||||
animation: speakPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* CSS Face */
|
||||
.face-css {
|
||||
@keyframes speakPulse {
|
||||
0%, 100% { box-shadow: 0 0 20rpx rgba(99, 102, 241, 0.3); }
|
||||
50% { box-shadow: 0 0 50rpx rgba(99, 102, 241, 0.6); }
|
||||
}
|
||||
.face-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hair {
|
||||
.mouth-overlay {
|
||||
position: absolute;
|
||||
top: -8%;
|
||||
left: -6%;
|
||||
width: 112%;
|
||||
height: 48%;
|
||||
background: radial-gradient(ellipse at 50% 100%, #3D2B1F 60%, transparent 72%);
|
||||
z-index: 2;
|
||||
border-radius: 50% 50% 0 0;
|
||||
}
|
||||
.face-skin {
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
background: radial-gradient(ellipse at 50% 40%, #FDE8D0, #F5D5B0);
|
||||
border-radius: 50%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Eyes */
|
||||
.eye {
|
||||
position: absolute;
|
||||
width: 20%;
|
||||
height: 18%;
|
||||
background: #FFFFFF;
|
||||
border-radius: 50%;
|
||||
top: 38%;
|
||||
z-index: 3;
|
||||
border: 2rpx solid #8B7355;
|
||||
overflow: hidden;
|
||||
}
|
||||
.eye::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 55%;
|
||||
height: 55%;
|
||||
background: radial-gradient(circle, #5B4033 30%, #1A1A1A 70%);
|
||||
border-radius: 50%;
|
||||
top: 22%;
|
||||
left: 22%;
|
||||
}
|
||||
.eye::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20%;
|
||||
height: 20%;
|
||||
background: #FFFFFF;
|
||||
border-radius: 50%;
|
||||
top: 18%;
|
||||
left: 28%;
|
||||
z-index: 4;
|
||||
}
|
||||
.eye-left { left: 18%; }
|
||||
.eye-right { right: 18%; }
|
||||
.eye.blink::after { display: none; }
|
||||
.eye.blink::before { display: none; }
|
||||
.eye.blink {
|
||||
background: #F5D5B0;
|
||||
border-color: #F5D5B0;
|
||||
height: 3%;
|
||||
top: 44%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Eyebrows */
|
||||
.eyebrow {
|
||||
position: absolute;
|
||||
width: 22%;
|
||||
height: 4rpx;
|
||||
background: #3D2B1F;
|
||||
border-radius: 4rpx;
|
||||
top: 32%;
|
||||
z-index: 3;
|
||||
}
|
||||
.eyebrow-left { left: 17%; transform: rotate(-6deg); }
|
||||
.eyebrow-right { right: 17%; transform: rotate(6deg); }
|
||||
|
||||
/* Nose */
|
||||
.nose {
|
||||
position: absolute;
|
||||
width: 8%;
|
||||
height: 12%;
|
||||
top: 46%;
|
||||
left: 46%;
|
||||
z-index: 3;
|
||||
border-left: 2rpx solid #E8C8A8;
|
||||
border-right: 2rpx solid #E8C8A8;
|
||||
border-radius: 0 0 50% 50%;
|
||||
}
|
||||
|
||||
/* Mouth */
|
||||
.mouth {
|
||||
position: absolute;
|
||||
bottom: 18%;
|
||||
bottom: 28%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 28%;
|
||||
height: 12%;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.mouth-inner {
|
||||
width: 22%;
|
||||
background: #C97B84;
|
||||
border-radius: 50%;
|
||||
transition: all 0.08s ease;
|
||||
transition: height 0.1s;
|
||||
}
|
||||
.mouth.speaking .mouth-inner {
|
||||
background: #B06570;
|
||||
box-shadow: inset 0 0 6rpx rgba(0,0,0,0.2);
|
||||
.mouth-overlay.open {
|
||||
background: #A85562;
|
||||
}
|
||||
|
||||
/* Blush */
|
||||
.blush {
|
||||
position: absolute;
|
||||
width: 16%;
|
||||
height: 10%;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 150, 150, 0.35);
|
||||
top: 54%;
|
||||
z-index: 2;
|
||||
}
|
||||
.blush-left { left: 8%; }
|
||||
.blush-right { right: 8%; }
|
||||
|
||||
/* Neck & body suggestion */
|
||||
.avatar-ring::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -4%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 30%;
|
||||
height: 12%;
|
||||
background: #F5D5B0;
|
||||
border-radius: 0 0 50% 50%;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
@@ -336,7 +189,6 @@ defineExpose({ play: playAudio, stop: stopAudio })
|
||||
padding: 4rpx 20rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
.mouth-canvas { display: none; }
|
||||
.speech-area {
|
||||
margin-top: 12rpx;
|
||||
padding: 0 40rpx;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "宇之然AI磁场",
|
||||
"appid": "__UNI__DEV__",
|
||||
"versionName": "1.0.8",
|
||||
"versionCode": "108",
|
||||
"versionName": "1.0.9",
|
||||
"versionCode": "109",
|
||||
"description": "职引 - 宇之然AI磁场旗下AI模拟面试平台,提供AI面试官模拟练习、简历智能优化、大厂面经题库,助你轻松应对校招面试。",
|
||||
"h5": {
|
||||
"title": "职引 - AI模拟面试 | 宇之然AI磁场",
|
||||
|
||||
Reference in New Issue
Block a user