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磁场",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" fill="none" shape-rendering="auto"><metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/"><rdf:RDF><rdf:Description><dc:title>Bottts</dc:title><dc:creator>Pablo Stanley</dc:creator><dc:source xsi:type="dcterms:URI">https://bottts.com/</dc:source><dcterms:license xsi:type="dcterms:URI">https://bottts.com/</dcterms:license><dc:rights>Remix of „Bottts” (https://bottts.com/) by „Pablo Stanley”, licensed under „Free for personal and commercial use” (https://bottts.com/)</dc:rights></rdf:Description></rdf:RDF></metadata><mask id="viewboxMask"><rect width="180" height="180" rx="0" ry="0" x="0" y="0" fill="#fff" /></mask><g mask="url(#viewboxMask)"><rect fill="#b6e3f4" width="180" height="180" x="0" y="0" /><g transform="translate(0 66)"><path d="M38 12c-2.95 11.7-19.9 6.67-23.37 18-3.46 11.35 8.03 20 17.53 20" stroke="#2A3544" stroke-width="6" opacity=".9"/><path d="M150 55c8.4 3.49 20.1-7.6 16-16.5-4.1-8.9-16-6.7-16-19.3" stroke="#2A3544" stroke-width="4" opacity=".9"/><mask id="sidesCables01-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="21" y="6" width="138" height="58"><g fill="#fff"><rect x="21" y="35" width="16" height="22" rx="2"/><rect x="136" y="42" width="23" height="22" rx="2"/><rect x="136" y="6" width="23" height="18" rx="2"/></g></mask><g mask="url(#sidesCables01-a)"><path d="M0 0h180v76H0V0Z" fill="#546e7a"/><path d="M0 0h180v76H0V0Z" fill="#fff" fill-opacity=".3"/></g></g><g transform="translate(41)"><g filter="url(#topGlowingBulb01-a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M32 24A16 16 0 0 1 48 8h4a16 16 0 0 1 16 16v8a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-8Z" fill="#fff" fill-opacity=".3"/></g><path d="M49 11.5c4.93 0 9.37 2.13 12.44 5.52" stroke="#fff" stroke-width="2" stroke-linecap="round"/><path d="m49.83 29-9-9L38 22.83l10 10V40h4v-7.03l10.14-10.14L59.31 20l-9 9h-.48Z" fill="#fff" fill-opacity=".8"/><rect x="22" y="40" width="56" height="12" rx="1" fill="#48494B"/><defs><filter id="topGlowingBulb01-a" x="24" y="0" width="52" height="48" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="4"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_617_621"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_617_621" result="shape"/><feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset/><feGaussianBlur stdDeviation="2"/><feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/><feBlend in2="shape" result="effect2_innerShadow_617_621"/></filter></defs></g><g transform="translate(25 44)"><mask id="faceRound01-a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="130" height="120"><path fill-rule="evenodd" clip-rule="evenodd" d="M66 0c58.35 0 64 40.69 64 78 0 33.31-25.47 42-64 42-37.46 0-66-8.69-66-42C0 40.69 7.65 0 66 0Z" fill="#fff"/></mask><g mask="url(#faceRound01-a)"><path d="M-4-2h138v124H-4V-2Z" fill="#546e7a"/><g transform="translate(-1 -1)"></g></g></g><g transform="translate(52 124)"><g fill="#000" fill-opacity=".6"><rect x="12" y="12" width="4" height="8" rx="2"/><rect x="36" y="12" width="4" height="8" rx="2"/><rect x="24" y="12" width="4" height="8" rx="2"/><rect x="48" y="12" width="4" height="8" rx="2"/><rect x="60" y="12" width="4" height="8" rx="2"/></g></g><g transform="translate(38 76)"><rect y="11" width="104" height="34" rx="17" fill="#000" fill-opacity=".8"/><circle cx="29" cy="28" r="13" fill="#F1EEDA"/><circle cx="75" cy="28" r="13" fill="#F1EEDA"/><rect x="24" y="23" width="10" height="10" rx="2" fill="#000" fill-opacity=".8"/><rect x="70" y="23" width="10" height="10" rx="2" fill="#000" fill-opacity=".8"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,83 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400">
|
||||
<defs>
|
||||
<radialGradient id="skinGrad" cx="50%" cy="40%">
|
||||
<stop offset="0%" stop-color="#FDE8D0"/>
|
||||
<stop offset="100%" stop-color="#F0D0A8"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="hairGrad" cx="50%" cy="80%">
|
||||
<stop offset="0%" stop-color="#4A3728"/>
|
||||
<stop offset="100%" stop-color="#2D1B1E"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="eyeWhite" cx="50%" cy="40%">
|
||||
<stop offset="0%" stop-color="#FFFFFF"/>
|
||||
<stop offset="100%" stop-color="#F0F0F0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="irisGrad" cx="40%" cy="35%">
|
||||
<stop offset="0%" stop-color="#6B4E37"/>
|
||||
<stop offset="70%" stop-color="#3D2B1F"/>
|
||||
<stop offset="100%" stop-color="#1A1108"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="shirtGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#4B5563"/>
|
||||
<stop offset="100%" stop-color="#374151"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<circle cx="200" cy="200" r="200" fill="#F0F2F5"/>
|
||||
|
||||
<!-- Hair back -->
|
||||
<ellipse cx="200" cy="120" rx="120" ry="100" fill="url(#hairGrad)"/>
|
||||
|
||||
<!-- Neck -->
|
||||
<rect x="165" y="270" width="70" height="50" rx="10" fill="url(#skinGrad)"/>
|
||||
|
||||
<!-- Shirt -->
|
||||
<ellipse cx="200" cy="350" rx="140" ry="80" fill="url(#shirtGrad)"/>
|
||||
<path d="M165 300 L200 340 L235 300" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.3"/>
|
||||
|
||||
<!-- Face -->
|
||||
<ellipse cx="200" cy="195" rx="100" ry="120" fill="url(#skinGrad)"/>
|
||||
|
||||
<!-- Hair front -->
|
||||
<path d="M100 160 Q100 80 200 70 Q300 80 300 160 Q280 130 200 125 Q120 130 100 160 Z" fill="url(#hairGrad)"/>
|
||||
<path d="M95 170 Q90 130 120 110 Q110 160 95 170 Z" fill="url(#hairGrad)"/>
|
||||
<path d="M305 170 Q310 130 280 110 Q290 160 305 170 Z" fill="url(#hairGrad)"/>
|
||||
|
||||
<!-- Left eyebrow -->
|
||||
<path d="M135 155 Q150 145 170 148" fill="none" stroke="#3D2B1F" stroke-width="3" stroke-linecap="round"/>
|
||||
|
||||
<!-- Right eyebrow -->
|
||||
<path d="M265 155 Q250 145 230 148" fill="none" stroke="#3D2B1F" stroke-width="3" stroke-linecap="round"/>
|
||||
|
||||
<!-- Left eye -->
|
||||
<ellipse cx="158" cy="175" rx="24" ry="18" fill="url(#eyeWhite)" stroke="#D4C4B0" stroke-width="1"/>
|
||||
<circle cx="158" cy="175" r="12" fill="url(#irisGrad)"/>
|
||||
<circle cx="158" cy="175" r="6" fill="#0A0A0A"/>
|
||||
<circle cx="153" cy="170" r="4" fill="#FFFFFF" opacity="0.8"/>
|
||||
<circle cx="163" cy="178" r="2" fill="#FFFFFF" opacity="0.5"/>
|
||||
|
||||
<!-- Right eye -->
|
||||
<ellipse cx="242" cy="175" rx="24" ry="18" fill="url(#eyeWhite)" stroke="#D4C4B0" stroke-width="1"/>
|
||||
<circle cx="242" cy="175" r="12" fill="url(#irisGrad)"/>
|
||||
<circle cx="242" cy="175" r="6" fill="#0A0A0A"/>
|
||||
<circle cx="237" cy="170" r="4" fill="#FFFFFF" opacity="0.8"/>
|
||||
<circle cx="247" cy="178" r="2" fill="#FFFFFF" opacity="0.5"/>
|
||||
|
||||
<!-- Nose -->
|
||||
<path d="M195 195 Q190 220 185 228 Q195 232 200 232 Q205 232 215 228 Q210 220 205 195" fill="none" stroke="#D4B896" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<ellipse cx="200" cy="230" rx="6" ry="3" fill="#E8C8A8" opacity="0.6"/>
|
||||
|
||||
<!-- Mouth -->
|
||||
<path d="M178 260 Q189 268 200 268 Q211 268 222 260" fill="none" stroke="#C97B84" stroke-width="2.5" stroke-linecap="round"/>
|
||||
|
||||
<!-- Cheek blush -->
|
||||
<ellipse cx="125" cy="225" rx="22" ry="14" fill="#FFB0A0" opacity="0.25"/>
|
||||
<ellipse cx="275" cy="225" rx="22" ry="14" fill="#FFB0A0" opacity="0.25"/>
|
||||
|
||||
<!-- Ear left -->
|
||||
<ellipse cx="100" cy="185" rx="12" ry="20" fill="url(#skinGrad)"/>
|
||||
|
||||
<!-- Ear right -->
|
||||
<ellipse cx="300" cy="185" rx="12" ry="20" fill="url(#skinGrad)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
Reference in New Issue
Block a user