diff --git a/src/views/im/home/pages/conversation/components/input/MessageInput.vue b/src/views/im/home/pages/conversation/components/input/MessageInput.vue
index 9092c2422..f2fdb50e0 100644
--- a/src/views/im/home/pages/conversation/components/input/MessageInput.vue
+++ b/src/views/im/home/pages/conversation/components/input/MessageInput.vue
@@ -76,6 +76,14 @@
+
+
+
+
+
@@ -125,6 +133,7 @@
+
@@ -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('editorRef')
const imageInputRef = useTemplateRef('imageInputRef')
const fileInputRef = useTemplateRef('fileInputRef')
+const videoInputRef = useTemplateRef('videoInputRef')
const mentionRef = useTemplateRef>('mentionRef')
// ==================== 文本 / 发送 ====================
@@ -772,6 +783,193 @@ async function onVoiceSend(payload: { blob: Blob; duration: number }) {
serializeMessage({ url, duration: payload.duration })
)
}
+
+// ==================== 视频 ====================
+type VideoProbe = {
+ duration?: number
+ width?: number
+ height?: number
+ cover?: Blob
+}
+
+const VIDEO_COVER_MAX_DIM = 720 // 封面最长边 cap:聊天列表里的视频封面没必要原视频分辨率,4K 原尺寸 jpeg 1-3MB 太浪费
+
+/**
+ * 加载视频本地预览,一次性拿到 metadata(duration / 宽高)+ 首帧封面 blob
+ *
+ * 一个 video 元素串两件事是为了避免重复 decode:metadata 解完后直接 seek 首帧再截图。
+ * 截图失败不抛异常,只让 cover 缺失,保证主流程仍能上传视频本体。
+ *
+ * finally 里显式断引用是因为:仅 revokeObjectURL 不足以让 video decoder 立即释放,
+ * 部分浏览器版本上 4K 视频解码 buffer 可滞留数十 MB 几秒到十几秒,连发几条会累计放大。
+ */
+async function probeVideoFile(file: File): Promise {
+ // 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((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 的极短视频)
+ // 部分浏览器不触发 onseeked,promise 会一直 pending 卡死整条链路
+ await new Promise((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 720(VIDEO_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 拿 jpeg;0.8 质量是聊天封面常用甜点
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
+ cover =
+ (await new Promise((resolve) =>
+ canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.8)
+ )) ?? undefined
+ // 2.5 提前释放 canvas backing store(4K 原尺寸 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 留空,气泡