feat(im): 增加视频消息

im
YunaiV 2026-05-01 09:47:01 +08:00
parent 82022b86de
commit 63c711f9e2
2 changed files with 232 additions and 6 deletions

View File

@ -76,6 +76,14 @@
<Icon icon="ant-design:audio-outlined" :size="18" />
</span>
</el-tooltip>
<el-tooltip content="发送视频" placement="top">
<span
class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@click="videoInputRef?.click()"
>
<Icon icon="ant-design:video-camera-outlined" :size="18" />
</span>
</el-tooltip>
</div>
<!-- 群聊发送按钮 + 下拉菜单点主按钮普通发送 / 发送回执消息对齐微信 PC -->
@ -125,6 +133,7 @@
<!-- 隐藏的文件选择器 -->
<input ref="imageInputRef" type="file" accept="image/*" hidden @change="onImagePicked" />
<input ref="fileInputRef" type="file" hidden @change="onFilePicked" />
<input ref="videoInputRef" type="file" accept="video/*" hidden @change="onVideoPicked" />
</div>
</template>
@ -145,7 +154,8 @@ import {
serializeMessage,
type ImageMessage,
type FileMessage,
type AudioMessage
type AudioMessage,
type VideoMessage
} from '@/views/im/utils/message'
import EmojiPicker from './EmojiPicker.vue'
@ -164,6 +174,7 @@ const { send, sendRaw } = useMessageSender()
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
const imageInputRef = useTemplateRef<HTMLInputElement>('imageInputRef')
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const videoInputRef = useTemplateRef<HTMLInputElement>('videoInputRef')
const mentionRef = useTemplateRef<InstanceType<typeof MentionPicker>>('mentionRef')
// ==================== / ====================
@ -772,6 +783,193 @@ async function onVoiceSend(payload: { blob: Blob; duration: number }) {
serializeMessage<AudioMessage>({ url, duration: payload.duration })
)
}
// ==================== ====================
type VideoProbe = {
duration?: number
width?: number
height?: number
cover?: Blob
}
const VIDEO_COVER_MAX_DIM = 720 // cap4K jpeg 1-3MB
/**
* 加载视频本地预览一次性拿到 metadataduration / 宽高+ 首帧封面 blob
*
* 一个 video 元素串两件事是为了避免重复 decodemetadata 解完后直接 seek 首帧再截图
* 截图失败不抛异常只让 cover 缺失保证主流程仍能上传视频本体
*
* finally 里显式断引用是因为 revokeObjectURL 不足以让 video decoder 立即释放
* 部分浏览器版本上 4K 视频解码 buffer 可滞留数十 MB 几秒到十几秒连发几条会累计放大
*/
async function probeVideoFile(file: File): Promise<VideoProbe> {
// 1. video
// 1.1 muted + preload=metadata
const objectUrl = URL.createObjectURL(file)
const video = document.createElement('video')
video.preload = 'metadata'
video.muted = true
video.src = objectUrl
try {
// 1.2 metadata duration / seek +
await new Promise<void>((resolve, reject) => {
video.onloadedmetadata = () => resolve()
video.onerror = () => reject(new Error('video metadata load error'))
})
// 1.3 duration NaN undefined
const meta = {
duration: Number.isFinite(video.duration) ? Math.round(video.duration) : undefined,
width: video.videoWidth || undefined,
height: video.videoHeight || undefined
}
// 2. try cover meta
let cover: Blob | undefined
try {
// 2.1 seek 0.1s < 0.2s 退 0
const seekTo = video.duration > 0.2 ? 0.1 : 0
// 2.2 seek + 3s currentTime 0
// onseekedpromise pending
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('video seek timeout')), 3000)
video.onseeked = () => {
clearTimeout(timer)
resolve()
}
video.onerror = () => {
clearTimeout(timer)
reject(new Error('video seek error'))
}
video.currentTime = seekTo
})
// 2.3 canvas cap 720VIDEO_COVER_MAX_DIM
const canvas = document.createElement('canvas')
const ratio = Math.min(1, VIDEO_COVER_MAX_DIM / Math.max(video.videoWidth, video.videoHeight))
canvas.width = Math.round(video.videoWidth * ratio)
canvas.height = Math.round(video.videoHeight * ratio)
const ctx = canvas.getContext('2d')
if (ctx && canvas.width && canvas.height) {
// 2.4 canvas toBlob jpeg0.8
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
cover =
(await new Promise<Blob | null>((resolve) =>
canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.8)
)) ?? undefined
// 2.5 canvas backing store4K 33MB GC
canvas.width = 0
canvas.height = 0
}
} catch (e) {
console.warn('[IM] 视频封面截取失败', e)
}
return { ...meta, cover }
} finally {
// 3. video
// 3.1 revoke objectUrl
URL.revokeObjectURL(objectUrl)
// 3.2 + unload decoder buffer MB
video.onloadedmetadata = null
video.onseeked = null
video.onerror = null
video.removeAttribute('src')
video.load()
}
}
/**
* 上传并发送 VIDEO 消息
*
* 1. probe 与视频上传同步起跑封面上传等 probe cover 后与视频上传竞速
* probe 解码 + 封面上传通常被视频上传时长完全遮蔽体感节省几百 ms 起步
* 2. 视频本体上传必须成功拿不到 url 就直接 return
* 3. 封面是锦上添花上传失败仅日志coverUrl 留空气泡 <video> 自带黑底播放按钮兜底
*
* 视频链路耗时长probe + 双上传上传期间用户切会话则放弃发送
* 否则会落到错误的会话里切走再切回来不算变化key 仍相等
*/
async function uploadAndSendVideo(file: File) {
// 1. key
// 1.1 key
const startConversation = conversationStore.activeConversation
if (!startConversation) {
return
}
const startKey = getConversationKey(startConversation)
// 2. probe probe cover
// 2.1 catch url=undefined step 3.2 url promise floating
const videoForm = new FormData()
videoForm.append('file', file)
const videoUploadPromise = (updateFile(videoForm) as Promise<{ data?: string }>).catch((e) => {
console.warn('[IM] 视频本体上传失败', e)
return { data: undefined as string | undefined }
})
// 2.2 probe + blob probe
const probePromise = probeVideoFile(file).catch((e): VideoProbe => {
console.warn('[IM] 视频元信息加载失败,降级为仅 url + size', e)
return {}
})
// 2.3 probe.cover coverUrl
const coverUploadPromise = probePromise.then(async (probe) => {
if (!probe.cover) {
return { probe, coverUrl: undefined as string | undefined }
}
try {
const coverForm = new FormData()
coverForm.append(
'file',
new File([probe.cover], `cover-${Date.now()}.jpg`, { type: 'image/jpeg' })
)
const coverUrl = ((await updateFile(coverForm)) as { data?: string })?.data || undefined
return { probe, coverUrl }
} catch (e) {
console.warn('[IM] 视频封面上传失败', e)
return { probe, coverUrl: undefined as string | undefined }
}
})
// 3.
// 3.1
const [videoRes, { probe, coverUrl }] = await Promise.all([
videoUploadPromise,
coverUploadPromise
])
// 3.2 url
const url = videoRes?.data
if (!url) {
return
}
// 3.3
const currentConversation = conversationStore.activeConversation
if (!currentConversation || getConversationKey(currentConversation) !== startKey) {
console.warn('[IM] 视频上传期间切换了会话,放弃发送', { startKey })
return
}
// 4. VideoMessage payload sendRaw / /
await sendRaw(
ImMessageType.VIDEO,
serializeMessage<VideoMessage>({
url,
coverUrl,
duration: probe.duration,
width: probe.width,
height: probe.height,
size: file.size
})
)
}
/** 视频选完即上传 + 发送 VIDEO 消息(不放入 editor整体走 sendRaw */
async function onVideoPicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (file) {
await uploadAndSendVideo(file)
}
}
</script>
<style scoped>

View File

@ -27,11 +27,31 @@
<span>{{ formatSeconds(voicePayload.duration ?? 0) }}</span>
</span>
<!-- 视频图标 + 占位文案 + 大小 -->
<span v-else-if="isVideo" class="inline-flex gap-1.5 items-center">
<Icon icon="ant-design:video-camera-filled" :size="16" color="#9c27b0" />
<span>[视频]</span>
<span v-if="videoPayload?.size" class="text-12px text-[var(--el-text-color-secondary)]">
<!-- 视频封面缩略图 + 时长 + 大小封面缺失时降级图标占位 -->
<span v-else-if="isVideo && videoPayload" class="inline-flex gap-1.5 items-center">
<span
v-if="videoPayload.coverUrl"
class="relative inline-block w-60px h-60px rounded overflow-hidden align-middle cursor-pointer"
:title="videoPayload.url ? '点击新标签播放' : ''"
@click="openVideo"
>
<img :src="videoPayload.coverUrl" class="w-full h-full object-cover" />
<Icon
icon="ant-design:play-circle-filled"
:size="22"
color="#fff"
class="absolute inset-0 m-auto pointer-events-none"
style="filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.6))"
/>
</span>
<span v-else class="inline-flex gap-1.5 items-center">
<Icon icon="ant-design:video-camera-filled" :size="16" color="#9c27b0" />
<span>[视频]</span>
</span>
<span v-if="videoPayload.duration" class="text-12px text-[var(--el-text-color-secondary)]">
{{ formatSeconds(videoPayload.duration) }}
</span>
<span v-if="videoPayload.size" class="text-12px text-[var(--el-text-color-secondary)]">
{{ formatFileSize(videoPayload.size) }}
</span>
</span>
@ -109,6 +129,14 @@ const videoPayload = computed(() =>
isVideo.value ? parseMessage<VideoMessage>(props.content || '') : null
)
/** 点击视频封面:在新标签打开视频 url不在管理后台内嵌播放避免列表里多个 video 同时占资源) */
function openVideo() {
const url = videoPayload.value?.url
if (url) {
window.open(url, '_blank')
}
}
/** 文件图标:按扩展名分配 icon + 颜色,对齐 home 端 MessageItem 的观感 */
const fileIconInfo = computed<{ icon: string; color: string }>(() => {
const name = filePayload.value?.name || ''