ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从播放到上传:封装浏览器视频工具库video-use的实战

从播放到上传:封装浏览器视频工具库video-use的实战 “video-use”这个名字看起来很简单就是“视频用法”或者“视频怎么用”但实际开发中它背后藏着一整套关于浏览器视频能力复用、封装与落地的工程化命题。我在项目里前后处理过视频播放、截图、录制、压缩、分片上传这些需求一开始每个功能都是现写现用后来发现负责播放的代码被复制了四五遍录制的逻辑在 A 项目能用、B 项目装不上截图黑屏问题换台机器又冒出来。于是我把这些能力抽成一套独立的视频功能模块就叫它 video-use。这篇文章不聊抽象架构也不谈造轮子的宏伟理想就说说我为什么这么拆、每一步怎么做、以及踩过的那些坑。适合正在做 Web 视频功能、准备封装视频工具库、或者被浏览器视频 API 折磨过一轮的朋友。1. 先搞清楚video-use 到底要解决什么问题很多项目对“视频功能”的理解是“页面上放一个video标签能播就行”。实际一接入业务就会发现播放只是最外层的一层皮。你要处理进度反馈、缓冲状态、失败重试你要支持截图取证、录制回放、本地预览还要解决转码压缩之后视频体积和清晰度的平衡最后再面对那一大坨上传分发的问题。这些功能如果全部堆在业务组件里过不了两个月这个组件就没人敢动了。1.1 视频需求不是“播放一个视频”那么简单我把常见需求列了一下大致能分成五类播放类播放/暂停、拖动进度、倍速、静音、画中画、全屏、字幕切换。捕获类截图、录制当前画面、录制摄像头/麦克风、逐帧导出。处理类剪辑裁剪、转码、压缩、生成封面、提取音频、加滤镜水印。上传类文件读取、进度上报、分片上传、断点续传、秒传校验。状态类loading、error、stalled、ended、timeupdate 同步以及各种异常边界。这些功能看似零散却依赖同一套底层能力HTMLMediaElement、Canvas、MediaRecorder、Web Audio API、File API。我当时的判断是与其在业务代码里东拼西凑不如把这套能力抽成统一工具集业务方只需要调用play()、captureFrame()、startRecord()不用关心底层 API 的兼容性和边界条件。1.2 为什么选择“组合式封装”而不是大而全的组件库我之前也用过一些重量级播放器库功能确实全但引入一个播放器就要顺带引入一堆样式和插件体系定制起来很费劲。对一个有强定制需求的业务来说我更倾向于“组合式”的方式视频的核心状态由一个可复用的模块管理UI 由业务自由拼装函数按需导入。这样既不会限制业务方的产品形态也能让工具集本身保持轻量。说得直白点就是给团队提供一个“工具箱”而不是直接送一个“成品家具”。2. 核心功能拆解一个视频工具集该有哪些“零件”我封装 video-use 之前先在纸上画了一遍功能边界。每个零件既要独立可用也要能串联成完整链路。比如“截图”这个功能表面上是一行canvas.toDataURL()但完整链路是加载视频元数据 → 确保视频帧已解码 → 绘制到 canvas → 导出图片 → 释放内存。链路里的每一步都可能出问题。2.1 播放控制不是简单的 play/pause播放控制最核心的不是调 API而是状态同步。视频上有play()、pause()、currentTime、duration、buffered、paused这些属性和事件但浏览器对自动播放策略极其严格桌面端 Chrome 只有用户手势触发后才能调用play()否则返回 rejected Promise。我在这部分做了几层处理统一封装play()内部捕获 Promise rejection 并转换成长效状态。监听timeupdate事件时只在currentTime变化超过阈值比如 250ms时才更新播放进度避免高频刷新导致页面掉帧。把duration的Infinity情况部分流媒体场景处理成未知长度UI 上显示为“直播中”。这些看似细碎但如果不统一收口每个业务组件都要重复处理一遍改都改不过来。2.2 画面捕获截图与逐帧分析的两种路子截图的需求在不同业务里不太一样。一种是要截取当前播放画面生成封面另一种是需要对视频逐帧抽帧做内容分析。前者直接用canvas.drawImage(video, 0, 0, width, height)就能完成但要注意 canvas 的尺寸要和视频实际分辨率对齐否则截出来是糊的。后者更麻烦你需要跳帧播放、等待seeked事件、再绘制每次 seek 之后都要留出解码时间否则拿到的是上一帧。这里有个常见反直觉点video.videoWidth和videoWidth是两个不同的属性前者是视频原始分辨率后者是标签的 CSS 宽度。截图必须用原始分辨率做最终输出CSS 尺寸只负责显示。2.3 音视频录制MediaRecorder 的组合玩法录制是 video-use 里比较有含金量的一部分。浏览器里录制视频有两种来源一种是把canvas的 2D/WebGL 上下文通过captureStream()变成视频流另一种是调用getUserMedia获取摄像头和麦克风流。两者可以混合比如录制一个“摄像头小窗 屏幕共享 背景音乐”的合成视频。MediaRecorder 最麻烦的是格式兼容。Chrome 支持video/webm;codecsvp9Safari 支持video/mp4同一个项目里不能硬编码一种格式。我在初始化录制器之前会先探测一遍 MIME 类型选第一个能被MediaRecorder.isTypeSupported接受的格式并在录制结束后根据 mimeType 决定输出文件的扩展名。2.4 转码压缩浏览器里能不能干“重活”以前团队里提到视频转码第一反应都是丢给服务端做 FFmpeg。但有些场景比如用户在本地选完视频后需要快速生成一个预览小体积版本或者需要把一段视频变成 GIF 动图如果全部走服务端上传和排队的时间会让体验大打折扣。浏览器端转码的思路是视频解码通过video标签播放本地 File 对象用URL.createObjectURL指向它。逐帧绘制把当前帧绘制到 canvas再通过canvas.captureStream()重新编码。音频处理通过Web Audio API的createMediaElementSource把视频里的音频源抽出来再合并到 MediaStream 中。浏览器端转码的性能和内存一直是痛点。一帧 1080p 的画面如果画到 canvas 上内存来去相当可观。我的策略是默认先按目标宽高比如 720px等比缩放避免在 canvas 上放大处理完一帧之后马上把 canvas 尺寸置零释放 GPU 内存如果视频时长太长渲染入口本身支持startTime duration的裁剪参数。2.5 上传链路断点续传是刚需视频文件动辄几十上百 MB直接axios.post一把梭很容易中断。我在这边封装了不少于三个层级本地读取 → 分片计算 → 并行上传。分片大小要根据网速动态调整我试过固定 5MB 切片在弱网环境下失败率明显偏高后来改为根据最近几片的上传耗时动态调整下一个分片大小。同时为了支持断点续传后台要提供一个“已上传分片列表”的接口前端启动时先问一遍再只传缺失的分片。这些功能不一定要全部一次性做完但架构上必须给后续扩展留好位置。video-use 的模块划分就保证了每一块零件都能独立迭代。3. 实操从零封装一套 video-use 工具接下来我把简化版代码拆开讲这套结构是我在项目里验证过几轮的你可以直接抄走也可以根据自己业务调整。3.1 基础结构怎么组织这些函数我使用的是 TypeScript Vue 3 组合式风格但核心逻辑没绑定任何框架改成函数式也能直接用在 React。目录大致是这样video-use/ ├── index.ts // 统一导出 ├── useVideo.ts // 播放器核心状态currentTime / duration / paused等 ├── useCapture.ts // 截图/录屏 ├── useRecord.ts // 摄像头/屏幕录制 ├── useCompress.ts // 转码压缩 └── useUpload.ts // 分片上传这里最重要的设计原则是状态和 DOM 隔离。useVideo接收一个 video 元素引用返回的是响应式状态和操作方法业务组件拿到这些状态后自由渲染。3.2 播放控制与状态同步import { ref } from vue; export function useVideo(videoEl: RefHTMLVideoElement | null) { const isPlaying ref(false); const currentTime ref(0); const duration ref(0); const bufferedPercent ref(0); const volume ref(1); let lastEmitTime -1; function updateState() { const el videoEl.value; if (!el) return; const now el.currentTime; // 节流距离上次发送超过250ms才更新 if (now - lastEmitTime 0.25) { currentTime.value now; lastEmitTime now; } duration.value el.duration; } async function play() { const el videoEl.value; if (!el) return; try { await el.play(); isPlaying.value true; } catch (e) { // 自动播放被拦截抛出业务错误由UI自定义处理 throw new Error(PLAY_BLOCKED); } } function pause() { const el videoEl.value; if (!el) return; el.pause(); isPlaying.value false; } function seekTo(time: number) { const el videoEl.value; if (!el) return; el.currentTime time; } function setVolume(v: number) { const el videoEl.value; if (!el) return; el.volume Math.min(1, Math.max(0, v)); volume.value el.volume; } // 事件绑定 function init() { const el videoEl.value; if (!el) return; el.addEventListener(timeupdate, updateState); el.addEventListener(play, () (isPlaying.value true)); el.addEventListener(pause, () (isPlaying.value false)); el.addEventListener(loadedmetadata, () { duration.value el.duration; }); } return { isPlaying, currentTime, duration, bufferedPercent, volume, play, pause, seekTo, setVolume, init }; }补充一个细节监听timeupdate时直接currentTime.value el.currentTime会触发 Vue 响应式更新频率取决于浏览器通常是 4~66Hz。如果页面同时渲染进度条、时间文本、预览图等高频更新会有明显开销因此我这里加了 0.25 秒的节流。进度条拖拽后需立即反馈的场景可以单独走seekTo里的手动赋值。3.3 截图与帧导出export function captureFrame(videoEl: HTMLVideoElement, { width, height, type image/png, quality 0.92, }: { width?: number; height?: number; type?: image/png | image/jpeg; quality?: number } {}) { if (!videoEl.videoWidth || !videoEl.videoHeight) { throw new Error(视频未加载元数据); } const canvas document.createElement(canvas); // 默认使用视频原始分辨率传width/height才缩放 const w width || videoEl.videoWidth; const h height || videoEl.videoHeight; canvas.width w; canvas.height h; const ctx canvas.getContext(2d); ctx.drawImage(videoEl, 0, 0, w, h); const dataUrl canvas.toDataURL(type, quality); // 释放内存 canvas.width 0; canvas.height 0; return dataUrl; }注意这里我用ctx.drawImage(videoEl, 0, 0, w, h)一次性传目标尺寸由浏览器内部完成缩放。如果你要逐帧抽帧做分析我建议把绘制前后的画面时间戳一起记录下来我踩过的坑是seek 到第 2 秒拿到seeked后再绘制结果画出来的是 1.8 秒的帧原因是浏览器解码器有向前寻址的行为需要在seeked后再强制设置一次currentTime或做双帧校验。3.4 录制与下载export async function startRecord(stream: MediaStream): PromiseMediaRecorder { const mimeType pickMimeType(); const recorder new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 2_500_000, }); const chunks: BlobPart[] []; recorder.ondataavailable (e) { if (e.data e.data.size 0) chunks.push(e.data); }; recorder.start(1000); // 每秒触发一次防止内存积压 return recorder; } function pickMimeType(): string { const candidates [ video/webm;codecsvp9, video/webm;codecsvp8, video/mp4, ]; return candidates.find((c) MediaRecorder.isTypeSupported(c)) || ; }保存录制文件的函数同理export function saveBlob(blob: Blob, fileName record.webm) { const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download fileName; a.click(); setTimeout(() URL.revokeObjectURL(url), 5000); }一个容易踩的坑是recorder.start(1000)的 timeslice 参数不能忽略。如果不传 timeslice只有在stop()时才会一次性触发ondataavailable长时间录制时浏览器会持续把数据堆在内部后面一次性输出超大 Blob内存飙升。传 1000ms 后浏览器会按秒把数据吐出来方便实时录屏反馈也让内存保持平稳。3.5 压缩转码这部分我只放核心骨架因为完整代码很长。核心思想是用视频作为“解码器”canvas 作为“重编码器”canvas.captureStream()与MediaRecorder共同生成新视频。export async function compressVideo(file: File, opts: { targetWidth?: number; targetHeight?: number; startTime?: number; duration?: number; }) { const url URL.createObjectURL(file); const video document.createElement(video); video.src url; video.muted true; video.playsInline true; await new Promise((resolve) { video.onloadeddata resolve; video.load(); }); const targetW opts.targetWidth || video.videoWidth; const targetH opts.targetHeight || video.videoHeight; const canvas document.createElement(canvas); canvas.width targetW; canvas.height targetH; const ctx canvas.getContext(2d); const stream canvas.captureStream(30); const recorder new MediaRecorder(stream, { mimeType: pickMimeType(), videoBitsPerSecond: 2_500_000 }); const chunks: BlobPart[] []; recorder.ondataavailable (e) { if (e.data.size 0) chunks.push(e.data); }; // 从 opts.startTime 开始抽帧到 opts.startTime opts.duration video.currentTime opts.startTime || 0; video.play(); const fps 30; const frameCount (opts.duration || video.duration) * fps; recorder.start(1000); for (let i 0; i frameCount; i) { const targetTime (opts.startTime || 0) i / fps; video.currentTime targetTime; await new Promise((resolve) { video.onseeked resolve; }); ctx.drawImage(video, 0, 0, targetW, targetH); } recorder.stop(); return new Blob(chunks, { type: recorder.mimeType }); }这里有个关键模型视频播放和 canvas 绘制是两个线程currentTime跳变之后必须等待seeked否则绘制的是旧帧。逐帧 seek 的做法在几十秒的视频内还能接受更长片源建议降低帧率或用 WebCodecs 做硬解否则速度会很感人。另外为什么video.muted true浏览器对播放有声视频有 autoplay 限制但 muted 视频允许自动播放。转码时我们不需要真实扬声器输出直接静音既躲开限制也不打扰用户。3.6 上传与进度分片上传最简洁的实现export async function uploadWithProgress(file: File, { url, chunkSize 5 * 1024 * 1024, onProgress, signal, }: { url: string; chunkSize?: number; onProgress?: (percent: number) void; signal?: AbortSignal; }) { const total file.size; let uploaded 0; let chunkIndex 0; const chunkList []; const maxChunk Math.ceil(total / chunkSize); // 先问后端哪些分片已上传 const uploadedSet await fetch(${url}?file${file.name}, { headers: { X-File-Hash: file.name } }) .then((r) r.json()) .then((res) new Set(res.list || [])) .catch(() new Set()); for (let start 0; start total; start chunkSize) { const chunk file.slice(start, Math.min(start chunkSize, total)); const idx chunkIndex; if (uploadedSet.has(idx)) { uploaded chunk.size; continue; } const form new FormData(); form.append(chunk, chunk); form.append(index, String(idx)); form.append(file, file.name); const resp await fetch(url, { method: POST, body: form, signal }); if (!resp.ok) { throw new Error(chunk ${idx} failed); } uploaded chunk.size; onProgress?.(Math.round((uploaded / total) * 100)); } return true; }这一段我没有做并发控制实际项目里并发数建议限制在 3~4避免弱网下碰倒大量请求排队。断点续传依赖后端返回的已上传分片集合前端只需要跳过即可服务端再按序号拼接。4. 实操中高频踩坑与排查实录这里我不讲大道理全是真金白银踩过的坑。我把最高频的五个问题整理成语录式排查清单每个都有自己的特征和解决方案。问题现象根因解决方案自动播放失败用户点击播放按钮无反应浏览器 Autoplay Policy 不允许带声音自动播放初始化时muted true用户手势后恢复声音或捕获play()rejection 后做 UI 引导video 截图黑屏截出来的图是黑色视频被 CORS 拦截或 canvas 跨域污染确保视频源开启 CORS 响应头video 标签加crossOriginanonymous本地文件不需要Infinity时长进度条显示 NaN流媒体场景 duration 为 Infinity判断!isFinite(duration)UI 转为直播态录制音画不同步合成视频画面超前/滞后canvas.captureStream 每帧推流时间戳不稳定手动设置canvas.captureStream(30)录制前等待一个稳定帧对音频流做延迟补偿内存暴涨转码几分钟的视频页面卡死canvas 未释放 chunks 数组无限攒每帧完成重置 canvas 尺寸录制完成后清空 chunks 并revokeObjectURL下面展开讲几个细节。4.1 自动播放被浏览器拦截后的处理手感不要只在控制台看到Uncaught (in promise) NotAllowedError就完事。业务层要区分“用户还没点过页面”“用户点过但浏览器依然拦截”“iOS 上静音视频自动播放成功但有声视频失败”这几种情况。我最终的处理办法是页面进入先用一个醒目的封面兜底不自动调用 play。用户点击任意位置后尝试播放并设置内存中的hasUserGesture标记。如果用户点击后依然失败再展示“轻触画面播放”的原生引导层。这套逻辑能让产品在 Chrome、Safari、微信内置浏览器之间保持一致体验。尤其是微信内置浏览器的规则和 Chrome 不一致必须用真实机型反复验证。4.2 截图黑屏的本质是 CORS 污染跨域视频不加 CORS 头canvas 会进入“被污染”状态任何读取像素的操作都会被浏览器拒绝输出黑图或无内容图片。这个问题在本地开发时因为同源往往注意不到部署到 CDN 或 OSS 后立刻爆炸。解决方案两件事OSS/CDN 配置Access-Control-Allow-Origin。video 标签在设置src之前先设置crossOrigin anonymous。注意顺序如果先设置了src再设置crossOrigin部分浏览器不会生效。这也是为什么我建议把 video 元素统一交给 video-use 内部创建和管理。4.3 录制音画不同步的排查路径我遇到过录制 10 分钟后视频画面比音频超前 1 秒的情况。排查过程分三步先检查canvas.captureStream(0)还是captureStream(30)。传 0 表示由 canvas 帧变化自动驱动时间戳但由于 drawImage 的时机不一定稳定时间戳会抖动。传固定 30 帧虽然会更均匀但如果机器性能不足实际 push 的帧率和 30 对不上也会导致播放器端按固定帧率推算时间出偏差。我的实际方案是录长视频时丢到服务端做后处理前端只做短片或预览如果非要前端合成则把音频源和视频源放进同一个MediaStream后立刻录制中间不要有任何异步处理。4.4 内存溢出不是库的锅是 canvas 没及时释放很多知乎吐槽视频转码崩溃的帖子最后定位下来都是canvas.width 0; canvas.height 0;这行代码没写。canvas 是有 GPU 后端的宽高很大的画布在显存里占用的空间远超 JS 堆。处理逐帧绘制时每帧结束都要重置否则显存和内存会同时累积。另外URL.createObjectURL创建的对象 URL 也要在不需要时revokeObjectURL不然页面会看到内存缓慢上涨。4.5 上传进度不准的原因属于“统计口径问题”进度条显示 100% 了但后端还没合成完毕这类问题基本不是前端的问题而是没有区分“上传字节数”和“服务端处理状态”。我在封装里把上报拆成两段一段是uploadProgress精确到字节另一段是mergeStatus轮询后端接口判断分片是否合并完成。这样 UI 上才不会有“明明传完了还在转菊花”的观感问题。5. 一些进阶经验与扩展方向video-use 目前这一套已经能支撑大多数 Web 视频场景了但有几个方向我还在持续打磨也建议你根据业务情况按需取用。5.1 性能优化避免录制转码时把 UI 卡到没法看转码、录屏这类 CPU 密集操作最好放到 Web Worker 里吗事实是 canvas 和 video 的操作无法全部在 Worker 里完成。一个折中方案是把计算密集型部分比如计算逐帧数据、颜色处理放 WorkerDraw 操作留在主线程但用requestAnimationFrame分帧执行每处理一帧就await一个宏任务给浏览器留出渲染机会。用户如果切到后台标签页requestAnimationFrame会被暂停这里要加降级策略改用setTimeout驱动并给用户显示“正在后台处理”。5.2 倍速播放下的音画同步playbackRate这个属性理论上浏览器会自动处理但在某些 Android 浏览器上倍速后音频会变得嘶哑或者播放速率变化但画面卡顿。我建议在倍速变化时function setRate(rate: number) { video.playbackRate rate; if (rate 0.5 || rate 2) { // 极端倍速下强制切换为无声播放避免音频变调 video.muted true; } }不过这样会导致用户听不到声音所以要有一个明确的静音状态提醒。更自然的方式是通过Web Audio API处理音频时间伸缩但这块复杂度高很多不是所有项目都需要。5.3 服务端配合不要把上传和转码都压在前端前端 video-use 做的是体验创新服务端能做的还有大量优化空间。比如秒传场景可以在选择文件后先算文件 hash提交给后端判断是否存在同一 hash存在就直接返回旧地址一堆大视频秒传成功。再比如转码前端可以抽低清预览服务端异步处理高清版本完成后通过消息推送刷新页面。前端工具库要与后端接口保持明确契约上传接口返回的字段建议统一为{ fileId, hash, status }前端据此判断下一步动作。我在实际操作中最大的体会是视频功能永远不是“加个video标签就完事”它要打通文件读取、媒体解码、绘制渲染、网络传输四个层面每一层都有各自的兼容性陷阱。而一款好的视频工具库最重要的不是提供尽可能多的 API而是把这些陷阱在内部消化掉让业务方的代码量减到最少。video-use 目前的版本已经做到了播放、截图、录制、压缩、上传五类能力全部即插即用后续我会继续把“音频波形提取”“直播流拉流”和“本地视频智能剪辑”这几个模块补上如果你也在做类似的方向欢迎一起交流踩坑心得。最后再分享一个小技巧所有涉及浏览器原生媒体 API 的方法写完后不要只在 Chrome 测试Safari 和 iOS 的 WebKit 对很多接口的行为跟 Chromium 有差异尤其是 MediaRecorder 的 mimeType 和 canvas 跨域策略。哪怕你觉得代码没问题也请务必在最真实的移动端环境里跑一遍那才是 video-use 真正发挥作用的地方。
返回列表