Bootstrap

uniapp实现裁剪图片-图片生成视频-视频精准定位到原图裁剪的位置(ai虚拟人、智能对话、图片生成视频相关,兼容微信小程序安卓和iOS端)

原图:
请添加图片描述
选框示例图:
请添加图片描述
裁剪后的图片:
请添加图片描述
合成视频示例:

AI虚拟人展示视频

裁剪代码:

<template>
	<view class="main_box">
		<view class="head_top" :style="{ height: headerHeight + 'px' }">
			<navheadbar :width='headerWidth' backTextColor='#fff' bgColor='transparent' bordColor='transparent'></navheadbar>
		</view>
		<view class="content_box">
			<view class="image_box">
				<image id='imgTemp' :src="bgImg" mode="widthFix" @load='loadImageHandle'></image>
				<view class="avatar_picker" :style="{ position: 'absolute', left: `${offsetSl}%`, top: `${offsetSt}%` }" id="avatar_picker" @touchmove='touchMoveHandle' @touchstart="touchStartHandle" @touchend="touchEndHandle">请拖动选框选中头部</view>
				<!-- <view class="avatar_picker" :style="{ position: 'absolute', left: `${offsetSl}%`, top: `${offsetSt}%` }" id="avatar_picker" @touchmove='touchMoveHandle' @touchstart="touchStartHandle" @touchend="touchEndHandle">{{ `x:${offsetSl}-y:${offsetSt}` }}</view> -->
			</view>
			<canvas v-if='ispost' class='canvs_box' id="myCanvas" canvas-id="myCanvas" :style="{ width: `${imagePickerData.width}px`, height: `${imagePickerData.height}px` }"></canvas>
		</view>
		<view class="config_btn" @click='clipAvatarHandle(bgImg)'>确认</view>
	</view>
</template>

<script>
	import { getSameDataMixin } from '@/utils/sameDataMixin.js'
	import { initDataBaseMixin } from '@/utils/sameMethodsMixin.js'
	// 导入vuex中状态遍历方法:
	import { mapState } from 'vuex'
	// 导入校验规则:
	import { useGetBoxSizeHandle, imgToBase64, setDataLocal } from '@/utils/offlineTools.js'
	import { fetchCommitImageMission } from '@/api/index.js'
	import { uploadImg } from '@/utils/onlinetools.js'
	export default {
		data() {
			return {
				...getSameDataMixin(),
				// 网络背景图地址:
				bgImg: '',
				// 采集盒子距离窗口的百分比距离
				offsetSl: 0,
				offsetSt: 0,
				// 鼠标开始坐标百分比距离:
				startclientX: 0,
				startclientY: 0,
				// 采集盒子可移动百分比距离
				canMoveLeft: 0,
				canMoveTop: 0,
				// 鼠标结束距离百分比距离:
				endclientX: 0,
				endclientY: 0,
				// canvas:
				ctx: {},
				// canvas容器是否渲染::
				ispost: false,
				// 防抖锁:
				isBtned: false,
				// 背景图片的数据
				imageData: {},
				// 选中图片框的数据:
				imagePickerData: {}
			}
		},
		onLoad(e) {
			if (e.param) {
				let obj = JSON.parse(decodeURIComponent(e.param))
				this.bgImg = obj.bgImg
			}
		},
		created() {
			initDataBaseMixin(this)
		},
		computed: {
		  ...mapState(['globalUserInfo', 'globalMyCard'])
		},
		async mounted(){
			// 2选中图片框的数据:
			const picker = uni.createSelectorQuery().in(this).select("#avatar_picker")
			this.imagePickerData = await this.getAvatarPickerInfoHandle(picker)
			// 渲染canvas:
			this.ispost = true
			console.log('选中框的数据:', this.imagePickerData) 
			// bottom: 348  height: 250  left: 0 right: 250 top: 98 width: 250
		},
		methods: {
			// 图片数据:
			async loadImageHandle(){
				// 图片数据:
				let avatbox = uni.createSelectorQuery().in(this).select(".image_box")
				this.imageData = await this.getAvatarPickerInfoHandle(avatbox)
				console.log('图片数据:', this.imageData)
				// bottom: 812  height: 718.84375 left: 0 right: 375 top: 93.15625 width: 375
			},
			// 按下位置
			async touchStartHandle(e) {
				// 鼠标开始在图片上的坐标百分比:
				this.startclientX = e.changedTouches[0].clientX / this.imageData.width * 100
				this.startclientY = (e.changedTouches[0].clientY - this.imageData.top) / this.imageData.height * 100

				// 选框在图片上可移动距离相对图片百分百:
				let { left, top, width, height } = this.imagePickerData
				// 可移动的距离:
				this.canMoveLeft = (this.imageData.width - width - this.offsetSl) / this.imageData.width * 100
				this.canMoveTop = (this.imageData.height - height - this.offsetSt) / this.imageData.height * 100
			},
			// 移动了
			touchMoveHandle(e){
				// 鼠标移动的百分比距离:
				let x = e.changedTouches[0].clientX / this.imageData.width * 100 - this.startclientX
				let y = (e.changedTouches[0].clientY - this.imageData.top) / this.imageData.height * 100 - this.startclientY

				// 移动选框:
				if (x > 0) {
					if (x <= this.canMoveLeft) {
						this.offsetSl = x
						this.endclientX = x
					} else {
						this.offsetSl = this.canMoveLeft
						this.endclientX = this.canMoveLeft
					}
				}
				if (x < 0) {
					if (0 <= (this.endclientX + x)) {
						this.offsetSl = this.endclientX + x
						this.endclientX = this.endclientX + x
					} else {
						this.offsetSl = 0
						this.endclientX  = 0
					}
				}
				if (y > 0) {
					if (y <= this.canMoveTop) {
						this.offsetSt = y
						this.endclientY = y
					} else {
						this.offsetSt = this.canMoveTop
						this.endclientY = this.canMoveTop
					}
				}
				if (y < 0) {
					if (0 <= (this.endclientY + y)) {
						this.offsetSt = this.endclientY + y
						this.endclientY = this.endclientY + y
					} else {
						this.offsetSt = 0
						this.endclientY  = 0
					}
				}
			},
			// 抬起位置
			touchEndHandle(e) {
				// 鼠标结束相对图片百分比距离:截取
				this.endclientX = this.offsetSl
				this.endclientY = this.offsetSt
			},
			// 裁剪图片:
			clipAvatarHandle(avatarUrl){
				const _this = this
				if (this.isBtned) return uni.showToast({ title: '请勿频繁操作!', icon: 'none', duration: 3000 })
				this.isBtned = true
				if (!avatarUrl) return uni.showToast({ title: '图片地址无效!', icon: 'none', duration: 3000 })
				this.ispost = true
				let avatarTemp = wx.env.USER_DATA_PATH + "/" + new Date().valueOf() + (Math.floor(Math.random() * 90000) + 10000) + avatarUrl.slice(avatarUrl.length - 5)
				// / 向用户发起授权请求
				uni.downloadFile({
					url: avatarUrl,
					filePath: avatarTemp,
					success: res => {
						console.log('下载成功', res)
						_this.getImgInfoHandle(avatarTemp)
					},
					fail: err => {
						console.log('err', err)
						uni.hideLoading()
						uni.showToast({ title: '保存失败!', icon: 'none' })
					}
				})
			},
			// 获取图片信息
			getImgInfoHandle(avatarTemp){
				const _this = this
				// 获取图片信息
				uni.getImageInfo({
					src: avatarTemp,
					success: avat => {
						_this.drawPosterHandle(avat)
					}
				})
			},
			// 绘制头像:
			drawPosterHandle(avat){
				const _this = this
				// 图片尺寸(需要绘制的图片尺寸):
				const imgW = this.imageData.width
				const imgH = this.imageData.height
				console.log('imgW', imgW)
				console.log('imgH', imgH)
				// 裁剪框尺寸:
				const { width, height } = this.imagePickerData
				console.log('width', width)
				console.log('height ', height )
				// 创建canvas上下文:
				this.ctx = uni.createCanvasContext("myCanvas", this)
				
				// 裁剪框:
				this.ctx.rect(0, 0, width, height)
				this.ctx.beginPath()
				// 绘制最外层容器:
				this.ctx.rect(0, 0, imgW, imgH)
				this.ctx.clip()
				
				// 计算canvas底图需要移动的实际像素:
				const x = imgW * this.offsetSl / 100
				const y = imgH * this.offsetSt / 100
				console.log('x', x)
				console.log('y', y)
				// 移动底图绘制到裁剪框
				this.ctx.drawImage(avat.path, -x, -y, imgW, imgH)
				this.ctx.save()
				// 下载海报:
				this.ctx.draw(true, () => {
					uni.canvasToTempFilePath({
						canvasId: "myCanvas",
						quality: 1,
						complete: async function (res) {
							_this.ispost = false
							let list = [ res.tempFilePath ]
							console.log('裁剪图本地地址:', res.tempFilePath)
							uni.saveImageToPhotosAlbum({
								filePath: res.tempFilePath,
								success: () => {
									_this.ispost = false
									uni.showToast({ title: "保存成功", icon: "none" })
									uni.hideLoading()
									console.log('进入9')
								},
								fail: err => {
									_this.ispost = true
								}
							})
							// 以下代码与上述:网络图片地址--裁剪选中---本地裁剪预览图地址---导入裁剪图到相册无关
							return
							let info = await uploadImg (list, '/appserv-card-xq/v1/cardTask/imageCheck', 'file' ,{ 'content-type': 'application/x-www-form-urlencoded' }, true).catch(err => {
								if (typeof err == 'object' && err !== null) {
									let errObj = JSON.parse(err.msg)
									uni.showToast({ title: errObj.msg || '形象复刻上传头像异常', icon: 'none', duration: 2000 })
								}
							})
							let uploadInfo = info[0]
							if (info && info[0]) {
								if (info[0].code == 0) {
									// 提交形象复刻训练
									_this.submitTranHandle(uploadInfo.data)
								}
							}
						}
					})
				})
			},
			// 提交形象训练任务:
			submitTranHandle(paths){
				if (!paths) return
				let obj = {
					fileUrl: paths
				}
				fetchCommitImageMission(obj).then(res => {
					if (res && res.code == 0) {
						let obj = {
							img: paths,
							currentStep: 3 , //复刻等待中
							trainTaskId: res.data,
							x: this.offsetSl,
							y: this.offsetSt,
							w: this.imageData.width,
							h: this.imageData.height,
							headImgUrl: paths,
							isSelect: 1,
							bottomImgUrl: this.bgImg
						}
						// 解锁:
						this.isBtned = false
						// 记录训练任务存本地:
						setDataLocal('lastTask', obj)
						uni.$emit('changeCurrentStep', obj)
						uni.navigateBack()
					} else {
						console.log('训练任务提交失败', res)
					}
				})
			},
			// 获取图片采集盒子信息:
			getAvatarPickerInfoHandle(e){
				return new Promise(resovle => {
					e.boundingClientRect(function(data) {
						resovle(data)
					}).exec()					
				})
			}
		}
	}
</script>

<style lang="scss" scoped>
	.main_box {
		@include mx_wh_bc_r(100%, 100%, $color: #333, $radius: false);
		@include mx_flex($direction: column);
		position: relative;
		.head_top {
			width: 100%;
			z-index: 99999;
		}
		.content_box {
			flex: 1;
			.image_box {
				position: relative;
				image {
					height: auto;
					width: 100%;
				}
				.avatar_picker {
					@include mx_lt($type: absolute, $left: 0, $top: 0);
					@include mx_wh_bc_r($width: 500rpx, $height: 500rpx, $color: false, $radius: false);
					background: rgba(255, 255, 255, .4);
					text-align: center;
					line-height: 500rpx;
					z-index: 99999;
				}
			}
			.canvs_box {
				position: absolute;
			}
		}
		.config_btn {
			@include mx_ct($type: absolute, $left: 50%, $top: 90%, $translateX: -50%, $translateY: 0);
			@include mx_wh_bc_r($width: 640rpx, $height: 100rpx, $color: #5A6AF6, $radius: 50rpx);
			@include mx_font($size: 42rpx, $color: #fff, $weight: false, $lineHeight: 100rpx, $fontFamily: false, $letterSpacing: false);
			text-align: center;
		}
	}
</style>

虚拟人展示页代码:只看视频盒子部分代码即可

<template>
	<view class="main_box">
		<!-- 视频盒子: -->
		<view class="video_box">
			<view class="image_box">
				<image :src="cardInfo.bottomImgUrl ? cardInfo.bottomImgUrl : ''" mode="widthFix"></image>
				<video  v-if='(cardInfo.unMouthImage || cardInfo.mouthImage)' :style="{ zIndex: `${isPlay ? 999 : -999}`, position: 'absolute', left: `${cardInfo.x || 35}%`, top: `${cardInfo.y || 20}%` }" object-fit="cover" ref="video" :src="cardInfo.mouthImage" :controls='false' :autoplay='true' :enableProgressGesture='false' :muted='true' loop :showFullscreenBtn='false' :showCenterPlayBtn='false' :showMuteBtn='false'></video>
				<video v-if='(cardInfo.unMouthImage || cardInfo.mouthImage)' :style="{position: 'absolute', left: `${cardInfo.x || 35}%`, top: `${cardInfo.y || 20}%` }" object-fit="cover" ref="video" :src="cardInfo.unMouthImage" :controls='false' :autoplay='true' :enableProgressGesture='false' :muted='true' loop :showFullscreenBtn='false' :showCenterPlayBtn='false' :showMuteBtn='false'></video>
			</view>
		</view>
		<view class="head_top" :style="{ height: headerHeight + 'px' }">
			<view class="go_home" @click.stop="goPageHandle">
				<image src="https://img.js.design/assets/img/6548a53d1719b1e81764c8ef.png" mode=""></image>
				<text>点我</text>
			</view>
			<view class="share_box" @click.stop="menuBtnClickHandle({ id: 1 })">
				<image src="../../static/images/shareIcon.svg" mode=""></image>
				<text>分享</text>
			</view>
		</view>
		<view class="content_box">
			<GoDetail btnTxt='查看详情' :isLook='true' :robotInfo='cardInfo'></GoDetail>
			<view class="chat_box">
				<scroll-view scroll-y="true" :style="{height: boxHeight + 'px'}" :scroll-into-view="currentMsgId" @scrolltoupper='getMoreChatListHandle'>
					<view :class='its.isMy ? "msg_box right_box" : "msg_box left_box"' v-for='(its, index) in chatList' :id='its.id'>
						<view class="msg">{{ its.printText }}</view>
						<view class="date"></view>
					</view>
				</scroll-view>
			</view>
			<view :class="keyboardHeight ? 'input_box top_key_bord_bg' : 'input_box'" :style="{ position: 'relative', top: `${keyboardHeight}px` }" >
				<image :src="isInput ? '../../static/images/icon-mike.png' : '../../static/images/icon_keyboard.png'" @click='isInput = !isInput'></image>
				<input :adjust-position="false" v-if='isInput' type="text" v-model="inputValue" placeholder='说点什么...' />
				<view v-else class="voice_btn" @touchstart="touchStartHandle" @touchend="touchEndHandle">按住说话</view>
				<view class="send_box" @click="sendMsgHandle">
					<image src="../../static/images/icon-send.png"></image>
					<text>发送</text>
				</view>
			</view>
		</view>
		<!-- 或者头像和昵称模态框 -->
		<modalbox v-if='contentBoxHeight' :isOpen='isOpenAvatorAndNickNameModal' :height='contentBoxHeight + headerHeight' top='0' left='0' translateY='0' translateX='0' bgColor='rgba(0, 0, 0, 0.6)' bordColor='rgba(0, 0, 0, 0.6)' v-slot:content>
			<GetAvatarNickName @close='closeHandle' @save='saveHandle'></GetAvatarNickName>
		</modalbox>
		<!-- 分享: -->
		<modalbox @close='menuBtnClickHandle({id: null})' v-if='contentBoxHeight' :isOpen='isOpenConfigShareModal' :height='contentBoxHeight + headerHeight + tabBarHeight' top='0' left='0' translateY='0' translateX='0' bgColor='rgba(0, 0, 0, 0.6)' bordColor='rgba(0, 0, 0, 0.6)' v-slot:content>
			<view class="share_main_box">
				<button open-type="share" @click.stop='menuBtnClickHandle({ id: 4 })'>
					<image src="../../static/images/wxFriend.png" mode=""></image>
					<text>微信好友</text>
				</button>
				<view @click.stop='menuBtnClickHandle({ id: 5 })'>
					<image src="../../static/images/cardPost.png" mode=""></image>
					<text>名片海报</text>
				</view>
			</view>
		</modalbox>
		<view class="copy_voice" v-if='isTouch'>
			<image src="../../static/images/speakGif.gif" mode=""></image>
			<text>手指上划,取消发送</text>
		</view>
	</view>
</template>

<script>
	import { terminalId, wxAppid } from '@/config/globalConfig.js'
	// 引入公共基本数据(头部导航栏尺寸、底部tabBar栏尺寸、是否为ios系统):
	import { getSameDataMixin } from '@/utils/sameDataMixin.js'
	// 引入: initDataBaseMixin基本数据初始化方法(headerHeight, headerWidth, tabBarHeight, isIos等变量的初识)、refreshIsLightOrDarkByTime朝夕模式自动刷新
	import { initDataBaseMixin } from '@/utils/sameMethodsMixin.js'
	// 导入本地工具
	import { navigationToHandle, useGetBoxSizeHandle, loginByWeChatHandle, imgToBase64, setDataLocal } from '@/utils/offlineTools.js'
	// 导入vuex中状态遍历方法:
	import { mapState } from 'vuex'
	// 导入接口:
	import { loginByWechatIvCodeApi, fetchLogin, getValidChatCountApi, deductChatNumApi, updateUserInfoApi, fetchCardDetail, fetchRecorderVisitor, fetchAnalyzeOpenId, fetchContactList, queryAvatarAndBgimgApi, recordChatMsgApi, fetchSearchRecord           , fetchVideoStatusByFileSeq, getUserInfoApi } from '@/api/index.js'
	// 引入组件:
	import navheadbar from '../../components/navheadbar/navheadbar.vue'
	import { uploadImg2 } from '@/utils/onlinetools.js'
	// 导入websocket
	import socketio from '@/socketio/index.js'
	import Vue from 'vue'
	export default {
		components: { navheadbar },
		onShareAppMessage(res) {
			return {
				title: `您好,我是${this.cardInfo.personName || '您的朋友'}, 我是第${this.cardInfo.id || 9999}个拥有数字分身的人类`,
				path: `/pages/aiChat/aiChat?scene=${this.cardInfo.cardId}`
			}
		},
		data() {
			return {
				// 基本数据:
				...getSameDataMixin(),
				// 中间有效区域的高度:
				boxHeight: 0,
				// 模态框高度:
				contentBoxHeight: 0,
				isOpenAvatorAndNickNameModal: false,
				// 分享弹框:
				isOpenConfigShareModal: false,
				// 消息列表:
				chatList: [],
				// 是否为输入类型:
				isInput: true,
				// 输入文本:
				inputValue: '',
				// 是否已经确认过:
				isLocked: true,
				// 卡片信息
				cardInfo: {},
				// 背景图:
				bgImg: '',
				// 是否播放:
				isPlay: false,
				// 队列消息:
				queueList: [],
				// 防抖:
				msgTimeId: 0,
				msgEndTimeId: 0,
				stopTimeId: 0,
				// 位置:
				touchStart: 0,
				touchEnd: 0,
				// 是否按下:
				isTouch: false,
				// 聊天消息id:
				currentMsgId: 0,
				// 缓存文本:
				bufferString: '',
				// 缓存文本定时器:
				bufferMsgTimeId: 0,
				// 语音播报锁:
				voiceLock: true,
				// 视频防抖:
				videoTimeId: 0,
				// 查询聊天记录请求参数:
				queryHistoryObj: {},
				// 页码
				pageNo: 1,
				// 键盘高度
				keyboardHeight: 0,
				// 当前是否为自己名片:
				currentIsMyCard: false
			}
		},
		watch: {
			'chatList': {
				handler(val){
					let tempObj = val[val.length - 1]
					if (!tempObj.isMy && !this.queryHistoryObj.isQueryHistory) {
						this.printTextHandle(val)
					} else {
						val[val.length - 1].printText = val[val.length - 1].content
					}
				}
			}, 
			'globalMyCard': {
				handler(val){
					if (this.currentIsMyCard) {
						this.cardInfo = val
					}
				}
			}
		},
		onShow(){
			this.$store.commit('updateGlobalCurrentCardMut', this.cardInfo)
		},
		computed: {
			...mapState(['globalScene', 'globalUserInfo', 'globalContacts', 'globalMyCard', 'globalCurrentCard'])
		},
		async mounted() {
			const info = uni.createSelectorQuery().select('.chat_box')
			this.boxHeight = await useGetBoxSizeHandle(info, 'height', [0, 0, 0, 0])
			let contentBox = uni.createSelectorQuery().select('.main_box')
			this.contentBoxHeight = await useGetBoxSizeHandle(contentBox, 'height', [0, 0, 0, 0])
			uni.onKeyboardHeightChange(res => {
			  this.keyboardHeight = -res.height
			})
		},
		onLoad(e){
			if (e.param) {
				let obj = JSON.parse(decodeURIComponent(e.param))
				// 查聊天记录:
				if (obj.uid && obj.isQueryHistory) {
					this.queryHistoryObj = obj
					this.queryChatHistory(obj.uid)
					this.pageNo = 1
				} else {
					this.queryHistoryObj = {}
				}
			} else {
				this.queryHistoryObj = {}
			}
			// 是否分享获取列表页进入
			if (e.scene) {
				this.$store.commit('updateGlobalSceneMut', e.scene)
			} else {
				this.$store.commit('updateGlobalSceneMut', null)
			}
			// 语音模块:
			this.$WSI.addCallBack('cardChat', this.onMsg)
			// 初始化数据(登录、自动播放一系列)
			this.initData()
		},
		onHide(){
			this.$WSI.pause()
		},
		destroyed(){
			this.$WSI.removeCallBack('cardChat')
			this.$ws.removeCallBack('acknowledge')
		},
		created(){
			// 初始化基本数据:
			initDataBaseMixin(this)
			// 初始化网络状态:
			this.initNetWorkStatus()
			// 监听聊天次数用完:
			uni.$on('aiChatFinish', () => uni.showToast({ title: '您的次数已用完', icon: 'none', duration: 2000 }))
			// 监听切换到自己名片:
			uni.$on('changeMyCard', myCard => {
				this.cardInfo = myCard
				this.$store.commit('updateGlobalCurrentCardMut', myCard)
			})
			
		},
		methods: {
			// 查询更多聊天记录:
			getMoreChatListHandle(){
				this.queryChatHistory()
			},
			// 查询聊天记录:
			queryChatHistory(uid){
				if (!this.queryHistoryObj.isQueryHistory) return
				let obj = {
					cardId: this.globalMyCard.cardId,
					uid: uid || this.globalCurrentCard.uid,
					pageNo: this.pageNo,
					pageSize: 10
				}
				fetchSearchRecord(obj).then(res => {
					if (res.code != 0) return
					if (res.data.list.length != 0) {
						this.pageNo ++
						let ls = []
						res.data.list.forEach(item => {
							if (item.message && item.message.trim() != '') {
								ls.push({ id: ('current' + item.id), isMy: (item.type == 'request'), printText: item.message })
							}
						})
						this.chatList.unshift(...ls)
					}
				})
			},
			// 打字效果:
			printTextHandle(val){
				let arrList = []
				let tempObj = val[val.length - 1]
				arrList = tempObj.content.split('')
				let i = 0
				function pushMsg(){
					val[val.length - 1].printText += arrList[i]
					i++
					if (i < arrList.length) {
						setTimeout(() => {
							pushMsg()
						}, 30)
					}
				}
				pushMsg()
			},
			// 说话:
			async touchStartHandle(e){
				if (!this.globalUserInfo.token) return uni.showToast({ title: '请先登录', icon: 'none', duration: 2000 })
				// 查询聊天次数是否有效:
				let num = await this.isValidChatCountHandle()
				if (num <= 0) return uni.showToast({ title: '您的次数已用完!', icon: 'none', duration: 2000 })
				this.touchStart = e.changedTouches[0].clientY
				this.touchEnd = e.changedTouches[0].clientY
				this.isTouch = true
				this.$WSI.pause()
				this.$WSI.touchStart()
			},
			// 停止说话:
			touchEndHandle(e){
				this.touchEnd = e.changedTouches[0].clientY
				this.isTouch = false
				if (this.touchStart - this.touchEnd > 10) {
					this.$WSI.close()
					this.$WSI.touchEnd()
					return
				}
				this.$WSI.touchEnd()
			},
			// 初始化:
			async initData(){
				const result = await loginByWeChatHandle().catch(err => {
					uni.showToast({ title: '登录失败', icon: 'none', duration: 3000, mask: true })
				})
				const obj = { wxCode: result.code, WxAppid: wxAppid, terminalId: terminalId }
				const idRes = await fetchAnalyzeOpenId(obj)
				console.log('idRes', idRes)
				if (idRes && idRes.code == 0) {
					const { openId, unionId } = idRes.data
					let tempOpenId = { ...this.globalUserInfo, openId, unionId }
					this.$store.commit('updateGlobalUserInfoMut', tempOpenId)
					// 用户登录
					const myLogin = await loginByWechatIvCodeApi({ unionId: unionId })
					let tempLoginMy = {
						...myLogin.data,
						token2: myLogin.data.token
					}
					const loginRes = await fetchLogin({ keytp: 'openid', uname: openId, terminalId: terminalId })
					if (loginRes.code != 0) return uni.showToast({ title: '登录失败1', icon: 'none', duration: 3000, mask: true })
					let tempLoginInfo = { ...this.globalUserInfo, ...tempLoginMy, ...loginRes.data }
					this.$store.commit('updateGlobalUserInfoMut', tempLoginInfo)
					// 连接socket.io
					this.connectIoHandle()
					// 添加wocket回调
					this.$ws.addCallBack('acknowledge', this.onMsgWebSocket)
					// 初始化名片信息:
					this.initCard()
				}
			},
			// 连接ws:
			connectIoHandle() {
				// 通过单例模式连接ws服务器:
				socketio.Instance.connect()
				// // 将websocket挂载到vue原型上:
				Vue.prototype.$ws = socketio.Instance
				// 添加wocket回调
				this.$ws.addCallBack('aiChatResp', this.onMsgWebSocket)
			},
			// 初始化名片(是否扫码或列表进入)
			initCard () {
				// 分享id(外部分享优先级最高,但仅限一次)
				let fetchShareId = ''
				// 是否记录访客
				let isRecorderVisitor = false
				// 扫描进入:
				if (this.globalScene) {
					// 分享名片进入-更改分享人名片id:
					fetchShareId = this.globalScene
					isRecorderVisitor = true
					// 调用完分享后清除扫码绑定的id
					this.$store.commit('updateGlobalSceneMut', null)
				} else if (this.globalContacts) {
					// 来自联系人页进入
					fetchShareId = this.globalContacts
					isRecorderVisitor = false
				}
				// 获取首页名片流程
				this.getCardInfo(fetchShareId, isRecorderVisitor)
				// 获取用户信息昵称头像:
				this.getUserInfoHandle()
			},
			// 查询名片详细信息:
			getCardInfo(share, isRecorder){
				// 有名片id时:
				if (share) {
					fetchCardDetail({ cardId: share }).then(async res => {
						if (res.code == 0 && res.data) {
							this.cardInfo = res.data
							this.currentIsMyCard = false
							this.avatarAndBgimgHandle()
							this.$store.commit('updateGlobalCurrentCardMut', res.data)
							// 注册webso事件
							this.addSocketHandle()
							// 分享进入记录访客:
							if (isRecorder) {
								this.recorderVisitor(share)
							}
							// 有自我介绍视频id时获取视频:
							if (res.data?.sound) {
								await this.getIntroVideo(res.data).catch(() => {
									this.playAudio(res.data)
								})
								this.playAudio(res.data)
							}
						}
					})
				} else {
					// 没有id时查询的是自己的名片
					fetchCardDetail().then(res => {
						if (res && res.code == 0 && res.data) {
							this.cardInfo = res.data
							console.log('自己的名片数据', res.data)
							this.currentIsMyCard = true
							this.avatarAndBgimgHandle(true)
							// setDataLocal('currentCard', res.data)
							this.$store.commit('updateGlobalCurrentCardMut', res.data)
							// 注册webso事件
							this.addSocketHandle()
							// 修改store中自己名片信息:
							this.$store.commit('updateGlobalMyCardMut', res.data)
							if (res.data.sound) {
								this.getIntroVideo(res.data).then((updateTip) => {
									if (updateTip) {
										uni.showToast({ title: '形象复刻已升级,您可在<我的-形象复刻>重新生成', icon: 'none', duration: 4000 })
									}
									this.playAudio(res.data)
								}).catch((err) => {
									this.playAudio(res.data)
								})
							}
						} else {
							// 不存在我的卡片,获取老板boss卡片
							fetchContactList({ isDefault: 1 }).then((res) => {
								if (res && res.code == 0) {
									const bossCard = res.data.list[0]
									this.cardInfo = bossCard
									this.currentIsMyCard = false
									this.avatarAndBgimgHandle()
									// if (!this.cardInfo.videoUrl) {
									// 	this.getCardInfo(this.cardInfo.cardId, false)
									// }
									// setDataLocal('CURRENT_CARD', bossCard)
									this.$store.commit('updateGlobalCurrentCardMut', bossCard)
									// 注册webso事件
									this.addSocketHandle()
									if (bossCard.sound) {
										// 获取自我介绍视频后播放音频
										this.getIntroVideo(bossCard).then(() => {
											this.playAudio(bossCard)
										}).catch((err) => {
											this.playAudio(bossCard)
										})
									}
								}
							})
						}
					})
				}
			},
			// 根据名片id查询名片照片信息:
			avatarAndBgimgHandle(isMy){
				let obj = {
					bmCardId: this.cardInfo.cardId,
					type: 0 // 0-形象照,1-证件照
				}
				queryAvatarAndBgimgApi(obj).then(res => {
					if (res.code == 200 && res.data) {
					console.log('执行了')
						let temp = {
							...this.cardInfo,
							x: res.data.x,
							w: res.data.w,
							h: res.data.h,
							y: res.data.y,
							myCardId: res.data.id,
							bottomImgUrl: res.data.bottomImgUrl
						}
						this.cardInfo = temp
						this.$store.commit('updateGlobalCurrentCardMut', temp)
						console.log('更新当前头像定位数据:', temp)
						if (isMy) {
							console.log('更新自己头像定位数据:', temp)
							// 修改store中自己名片信息:
							this.$store.commit('updateGlobalMyCardMut', temp)
						}
					}
				})
			},
			// 获取用户信息:
			getUserInfoHandle(){
				getUserInfoApi().then(res => {
					if (res.code == 0) {
						let temp = {
							...this.globalUserInfo,
							...res.data
						}
						this.$store.commit('updateGlobalUserInfoMut', temp)
						// 是否有昵称或头像
						if ((!this.globalUserInfo.nickname) || (!this.globalUserInfo.imgUrl)) {
							this.isOpenAvatorAndNickNameModal = true
						}
					}
				})
			},
			// 获取自我介绍视频(resolve是否需要提示形象复刻升级)
			getIntroVideo(cardData) {
				const { videoUrl = '', robotId = '', mouthImage = '', unMouthImage = '' } = cardData
				return new Promise((resolve, reject) => {
					if (mouthImage && unMouthImage) {
						// 存在已生成的形象,直接使用
						this.introVideo = mouthImage
						this.staticVideo = unMouthImage
						this.defaultVideo = ''
						resolve(false)
						return
					}
					// 废弃-------------------------------------------------------------------------------------------------------
					if (videoUrl || robotId) {
						// 未生成形象,从老形象接口获取
						fetchVideoStatusByFileSeq({ fileSeq: videoUrl || robotId }).then((res) => {
							if (res && res.code === 0 && res.data?.status === 1) {
							// 视频资源地址(在线形式)
							const resUrl = res.data?.minioData?.[0]?.name
							this.introVideo = ''
							this.staticVideo = ''
							this.defaultVideo = resUrl
							// 仅当视频是老的版本,进行提示
							resolve(true)
							} else {
								this.introVideo = ''
								this.staticVideo = ''
								this.defaultVideo = ''
								reject(new Error('视频接口请求失败'))
							}
						})
					} else {
						this.introVideo = ''
						this.staticVideo = ''
						this.defaultVideo = ''
						resolve(false)
					}
					// 废弃-------------------------------------------------------------------------------------------------------
				})
			},
			// 自动播放自我介绍音频
			playAudio(cardInfo) {
				this.isPlay = true
				this.$WSI.textToVoice(cardInfo.personalProfile, cardInfo.soundUrl, true, this.cardInfo.sound)
			},
			// 注册webSocket事件回调:
			addSocketHandle(){
				// 1.注册websock事件:
				let objTemp = {
					method: "authReq",
					robotId: this.globalCurrentCard.robotId,
					userIdentity: this.globalUserInfo.openId
				}
				this.$ws.send(objTemp)
				this.$nextTick(() => {
					let objTemp1 = {
						method: "heartbeat",
						userIdentity: this.globalUserInfo.openId
					}
					this.$ws.send(objTemp1)
				})
			},
			// 新增好友:
			recorderVisitor(shareId) {
				const payload = {
					cardId: shareId
				}
				fetchRecorderVisitor(payload)
			},
			// 初始化网络状态和监听网络状态变化:
			initNetWorkStatus(){
				let status = this.$NET.getNetWorkStatus()
				this.$store.commit('updateGlobalNetWorkStatusMut', status)
				// 监听网络状态发生变化:
				this.$NET.addCallBack('netWorkStatus', this.updateNetWork)
			},
			// 需改网络状态:
			updateNetWork(e){
				this.$store.commit('updateGlobalNetWorkStatusMut', e)
				if (!e) {
					uni.showLoading({ title: '网络连接已断开,正在尝试重连!', mask: false })
				} else {
					uni.hideLoading()
				}
			},
			// 发文本:
			async sendMsgHandle(){
				if (!this.inputValue) return
				// 查询聊天次数是否有效:
				let num = await this.isValidChatCountHandle()
				if (num <= 0) return uni.showToast({ title: '您的次数已用完!', icon: 'none', duration: 2000 })
				const obj = {
					type: 'voiceToText',
					content: this.inputValue,
					isMy: true
				}
				this.onMsg(obj)
				this.inputValue = ''
			},
			// 监听语音模块文字推送:
			onMsg(msg){
				if (msg == 'stopVideo') {
					// 停止播放后从队列中继续拿第一条文字转语音
					clearTimeout(this.msgTimeId)
					this.msgTimeId = setTimeout(() => {
						if (this.queueList[0] && this.isPlay) {
							this.$WSI.textToVoice(this.queueList[0], null, true, this.cardInfo.sound)
							this.isPlay = false
						} else {
							clearTimeout(this.stopTimeId)
							this.stopTimeId = setTimeout(() => {
								this.isPlay = false
							}, 300)
						}
					}, 1000)
				} else if (msg == 'playVideo') {
					// 视频开始播放时,删除队列中上一次第一条文字:
					this.msgEndTimeId = setTimeout(() => {
						if (this.queueList.length != 0 && (!this.isPlay)) {
							// 播放视频:
							this.isPlay = true
							this.queueList.shift()
						}
					}, 800)
				} else if (msg?.type == 'voiceToText') {
					let objTemp = {
						cardId: this.globalCurrentCard.cardId,
						chatModel: 1,
						contents: [{
							type: '文本',
							data: {
								content: msg.content,
							},
						}],
						method: "aiChatReq",
						robotId: this.globalCurrentCard.robotId,
						terminalId: terminalId,
						userIdentity: this.globalUserInfo.openId
					}
					let rid = 'id' + Math.floor(Math.random()*(9-99999999)+99999999) + '' + Date.parse(new Date())
					this.currentMsgId = rid
					const obj = {
						type: 'voiceToText',
						content: msg.content,
						isMy: true,
						id: rid
					}
					this.chatList.push(obj)
					this.$ws.send(objTemp)
					// 扣减聊天次数:
					let objNum = {
						userId: this.globalUserInfo.id
					}
					deductChatNumApi(objNum)
					// 记录聊天消息
					let redMsg = {
						message: msg.content,
						receiveUserId: this.globalCurrentCard.cardId,
						sendUserId: this.globalUserInfo.id,
						type: 0
					}
					recordChatMsgApi(redMsg)
				}
			},
			// 判断聊天次数余额是否有效:
			isValidChatCountHandle(){
				return new Promise(resolve => {
					let obj = {
						userId: this.globalUserInfo.id
					}
					getValidChatCountApi(obj).then(res => {
						if (res.code != 200) return uni.showToast({ title: res.msg || '查询聊天次数接口异常!', duration: 2000, icon: 'none' })
						resolve(res.data)
					})
				})
			},
			// 消息推送过来:
			onMsgWebSocket(data) {
				if (!(data.contents && data.contents.length != 0)) return
				let contentTemp = data.contents[0]
				if (contentTemp.type == '文本') {
					this.bufferString = this.bufferString + contentTemp.data.content
					clearTimeout(this.bufferMsgTimeId)
					this.bufferMsgTimeId = setTimeout(() => {
						this.sendMsg(this.bufferString)
						this.bufferString = ''
						this.voiceLock = true
					}, 300)
				} else if (contentTemp.type == '缓冲文本') {
					this.sendMsg(contentTemp.data.content)
				}
			},
			sendMsg(msgContent) {
				if (msgContent == '') return
				let rid = 'current' + Math.floor(Math.random()*(9-99999999)+99999999) + '' + Date.parse(new Date())
				this.currentMsgId = rid
				// 队列中添加数据:
				this.queueList.push(msgContent)
				const obj = {
					type: 'voiceToText',
					content: msgContent,
					printText: '',
					isMy: false,
					id: rid
				}
				this.chatList.push(obj)
				// 当为播放时才可以执行文字转语音
				if (this.voiceLock && !this.isPlay) {
					this.$WSI.textToVoice(this.queueList[0], null, true, this.cardInfo.sound)
					this.voiceLock = false
				}
				// 记录聊天消息
				let redMsg = {
					message: msgContent,
					receiveUserId: this.globalUserInfo.id,
					sendUserId: this.globalCurrentCard.cardId,
					type: 0
				}
				recordChatMsgApi(redMsg)
			},
			// 关闭获取头像和昵称弹框
			closeHandle(){
				this.isOpenAvatorAndNickNameModal = false
				// 是否去到进入选择页面;
				this.isSelectPageHandle()
			},
			// 保存头像和昵称(提交信息)
			async saveHandle(e){
				let list = []
				// 提交信息:
				let obj = {
					nickname: e.nickname,
					id: this.globalUserInfo.id
				}
				if (!e.isReandomCreate) {
					// 上传头像
					list = await uploadImg2([e.avatarUrl], '/common/upload', 'file')
					obj.imgUrl = list[0]
				} else {
					obj.imgUrl = e.avatarUrl
				}
				updateUserInfoApi(obj).then(res => {
					if (res.code != 200) return uni.showToast({ title: res.msg || '设置头像和昵称接口异常!', duration: 2000, icon: 'none' })
					let temp = {
						...this.globalUserInfo,
						nickname: e.nickname,
						imgUrl: obj.imgUrl
					}
					this.$store.commit('updateGlobalUserInfoMut', temp)
					this.isOpenAvatorAndNickNameModal = false
					// 是否自动进入选择页面;
					// this.isSelectPageHandle()
				})
			},
			// 跳转页面:
			goPageHandle(){
				if (!this.globalUserInfo.id) return uni.showToast({ title: '登录异常!', duration: 2000, icon: 'none' })
				// 没有填写昵称和头像时:
				if (((!this.globalUserInfo.nickname) || (!this.globalUserInfo.imgUrl)) && this.isLocked) {
					this.isOpenAvatorAndNickNameModal = true
					this.isLocked = false
					return
				}
				// 是否去到进入选择页面;
				this.isSelectPageHandle()
			},
			// 是否到选择进入页面:
			isSelectPageHandle(){
				// 判断是否付过款:
				if (!this.globalUserInfo.vipConfigId) {
					navigationToHandle('pageA', 'homePage')
				} else {
					navigationToHandle('pages', 'index')					
				}
			},
			// 按钮处理:
			menuBtnClickHandle(e){
				if (e.id == 1) {
					// 分享弹框:
					this.isOpenConfigShareModal = true
				} else if (e.id == 5) {
					// 海报下载
					this.isOpenConfigShareModal = false
					navigationToHandle('pageA', 'sharePoster', this.cardInfo)
				} else {
					// 关闭分享弹框:
					this.isOpenConfigShareModal = false
				}
			}
		}
	}
</script>
<style lang='scss' scoped>
	.main_box {
		@include mx_wh_bc_r(100%, 100%, $color: false, $radius: false);
		@include mx_flex($direction: column);
		position: relative;
		.share_main_box {
			@include mx_flex($direction: row, $justify: false, $align: false);
			@include mx_lb($type: absolute, $left: false, $bottom: 100rpx);
			@include mx_wh_bc_r(100%, 550rpx, $color: #fff, $radius: 10rpx);
			view, button {
				@include mx_flex($direction: column, $justify: false, $align: center);
				@include mx_mp($boxSize: border-box, $padding: 78rpx 0 0, $margin: false);
				flex: 1;
				@include mx_wh_bc_r($width: false, $height: 100%, $color: #fff, $radius: false);
				&::after {
					border: none;
					background: transparent;
				}
				image{
					@include mx_wh_bc_r(120rpx, 120rpx, $color: false, $radius: 50%);
				}
				text{
					margin-top: 11rpx;
					heihgt: 33rpx;
					@include mx_font($size: 24rpx, $color: #333, $weight: 400, $lineHeight: 33rpx, $fontFamily: false);
				}
			}
		}
		.head_top {
			position: relative;
			@include mx_wh_bc_r($width: 100%, $height: false, $color: false, $radius: false);
			.go_home {
				@include mx_flex($direction: row, $justify: center, $align: center);
				@include mx_wh_bc_r($width: 112rpx, $height: 51rpx, $color: false, $radius: 26rpx);
				@include mx_lb($type: absolute, $left: 25rpx, $bottom: 20rpx);
				@include mx_clearbtn($bgcolor: transparent);
				z-index: 99999;
				image {
					@include mx_wh_bc_r($width: 40rpx, $height: 40rpx, $color: false, $radius: false);
				}
				text {
					@include mx_flex($direction: row, $justify: center, $align: center);
					@include mx_font($size: 18rpx, $color: #fff, $weight: false, $lineHeight: false, $fontFamily: false, $letterSpacing: false);
				}
				border: 1rpx solid #fff;
			}
			button:after {
				border: none;
				border-radius: none;
			}
			.share_box {
				@include mx_flex($direction: row, $justify: false, $align: center);
				@include mx_lb($type: absolute, $left: 400rpx, $bottom: 20rpx);
				@include mx_wh_bc_r($width: false, $height: 50rpx, $color: false, $radius: false);
				image {
					@include mx_wh_bc_r($width: 32rpx, $height: 32rpx, $color: false, $radius: false);
				}
				text {
					margin-left: 10rpx;
					@include mx_font($size: 22rpx, $color: #fff, $weight: false, $lineHeight: false, $fontFamily: false, $letterSpacing: false);
				}
			}
		}
		.content_box {
			flex: 1;
			@include mx_flex($direction: column, $justify: flex-end, $align: false);
			position: relative;
			overflow: hidden;
			.chat_box {
				margin: 0 auto;
				@include mx_wh_bc_r($width: 90%, $height: 500rpx, $color: false, $radius: false);
				.msg_box {
					@include mx_flex($direction: row, $justify: false, $align: center);
					width: 100%;
					margin: 10rpx 0;
					.msg {
						@include mx_mp($boxSize: border-box, $padding: 20rpx, $margin: 33rpx 0 0);
						@include mx_font($size: 26rpx, $color: #fff, $weight: 700, $lineHeight: false, $fontFamily: false);
					}
				}
				.left_box {
					flex-direction: row;
					.msg {
						@include mx_wh_bc_r($width: false, $height: false, $color: rgba(0, 186, 173, 0.3), $radius: 0 14rpx 14rpx 14rpx);
					}
					.date {
						padding-left: 32rpx;
					}
				}
				.right_box {
					flex-direction: row-reverse;
					.msg {
						@include mx_wh_bc_r($width: false, $height: false, $color: rgba(213, 119, 247, 0.3), $radius: 14rpx 0 14rpx 14rpx);
					}
					.date {
						padding-right: 32rpx;
					}
				}
			}
			.input_box {
				@include mx_flex($direction: row, $justify: false, $align: center);
				@include mx_wh_bc_r(100%, 150rpx, $color: flase, $radius: false);
				border-top: 1rpx solid transparent;
				z-index: 9999;
				image {
					margin: 0 20rpx;
					@include mx_wh_bc_r($width: 55rpx, $height: 55rpx, $color: false, $radius: 50%);
				}
				input, .voice_btn {
					flex: 1;
					@include mx_font($size: 28rpx, $color: #fff, $weight: false, $lineHeight: false, $fontFamily: false, $letterSpacing: false);
					@include mx_mp($boxSize: border-box, $padding:  0 30rpx, $margin: false);
					@include mx_wh_bc_r($width: false, $height: 66rpx, $color: #333, $radius: 36rpx);
				}
				.voice_btn {
					text-align: center;
					line-height: 58rpx;
				}
				.send_box {
					margin: 0 20rpx;
					@include mx_wh_bc_r($width: 120rpx, $height: 66rpx, $color: false, $radius: 36rpx);
					@include mx_flex($direction: row, $justify: false, $align: center);
					box-sizing: border-box;
					border: 1rpx solid #fff;
					image {
						margin: 0 10rpx;
						@include mx_wh_bc_r($width: 40rpx, $height: 40rpx, $color: false, $radius: 50%);
					}
					text {
						@include mx_font($size: 22rpx, $color: #fff, $weight: false, $lineHeight: false, $fontFamily: false, $letterSpacing: false);
					}
				}
			}
			.top_key_bord_bg {
				background: #D1D1DB;
			}
		}
		background-color: #222629;
		opacity: 0.9;
		.video_box {
			@include mx_ct($type: absolute, $left: 50%, $top: 50%, $translateX: -50%, $translateY: -50%);
			@include mx_wh_bc_r($width: 100%, $height: 100%, $color: false, $radius: false);
			z-index: -99;
			.image_box {
				@include mx_ct($type: relative, $left: 50%, $top: 50%, $translateX: -50%, $translateY: -50%);
				image {
					height: auto;
					width: 100%;
				}
				video {
					@include mx_lt($type: absolute, $left: 0, $top: 0);
					@include mx_wh_bc_r($width: 500rpx, $height: 500rpx, $color: false, $radius: false);
					@include mx_mp($boxSize: border-box, $padding: 0, $margin: 0);
				}
			}
		}
		.copy_voice {
			@include mx_wh_bc_r(262rpx, 262rpx, $color: false, $radius: false);
			@include mx_ct($type: absolute, $left: 50%, $top: 44%, $translateX: -50%, $translateY: -50%);
			@include mx_bic($url: '../../static/images/speakWrap.png', $color: false, $size: cover, $repeat: no-repeat);
			@include mx_mp($boxSize: border-box, $padding: 80rpx 0 0, $margin: false);
			text-align: center;
			z-index: 9999;
			image {
				@include mx_wh_bc_r(100%, 45%, $color: false, $radius: false);
			}
			text {
				@include mx_font($size: 22rpx, $color: #fff, $weight: 400, $lineHeight: 45rpx, $fontFamily: false);
			}
		}
	}
</style>

实现思路:

图片采集页面和聊天页面放置底图的容器尺寸都是:宽度100%,高度由图片撑开,视频和图片抓取尺寸定位一致,定位都相对于底图按百分比计算,图片采集实现步骤推导如下:
请添加图片描述

请添加图片描述

请添加图片描述
疑问解答:嘴巴会动、眼睛会动、安卓端裁剪的图片生成的视频在ios端底部有1像素留白(兼容性问题,后面可优化,上述代码开发阶段,有bug正常,再优化迭代中…)

提示:本文图片等素材来源于网络,若有侵权,请发邮件至邮箱:[email protected]联系笔者删除。

笔者:苦海123

其它问题可通过以下方式联系本人咨询:

QQ:810665436

微信:ConstancyMan

;