同步与异步上传OSS实战:图片音频视频的分层架构与工程落地
1. 项目概述为什么“同步/异步上传OSS”成了日常开发绕不开的硬需求你有没有遇到过这样的场景用户刚拍完一张高清图点击“发布”页面卡住三秒进度条不动最后弹出“上传失败请重试”或者在后台批量处理500条带语音评论的数据时整个服务响应变慢连带登录接口都开始超时又或者视频剪辑App里用户导出成品后点“分享到云端”结果手机锁屏两分钟再亮屏发现上传早已中断——这些不是偶发Bug而是文件上传路径设计失当的典型症状。而标题里这短短十个字——“同步/异步 上传oss整理 图片 音频 视频”——恰恰戳中了当前绝大多数Web、小程序、App及后台服务在媒体资源管理中最基础也最易被轻视的一环如何让文件上传这件事既可靠、又不拖垮用户体验和系统稳定性。这里的“OSS”不是泛指而是特指对象存储服务Object Storage Service这一类云存储基础设施比如阿里云OSS、腾讯云COS、华为云OBS等。它们共同特点是高可用、高扩展、按量付费、天然支持HTTP直传但不提供“上传即完成”的强一致性语义——文件写入成功 ≠ 元数据就绪 ≠ 可被业务逻辑安全引用。而“图片/音频/视频”这三类文件又分别代表了三种截然不同的上传挑战图片体积小但并发高如社交App头像朋友圈九宫格、音频单体不大但格式碎片化MP3/WAV/AMR/M4A、视频则兼具大体积动辄几十MB起步、长耗时、高失败率网络抖动下极易中断三大特性。至于“同步/异步”这个选择根本不是技术炫技而是业务权衡前端表单提交后必须等图片缩略图生成才能显示还是允许先存草稿后台异步转码、打标、审核前者用同步更可控后者用异步才可伸缩。我做过不下20个涉及媒体上传的项目从某高校在线实验平台的课程录播上传到某本地生活App的商户短视频入驻系统再到某智能硬件公司的设备固件日志音视频诊断包回传服务。踩过的坑几乎一模一样一开始全用同步直传测试环境丝滑上线后QPS一过50OSS签名生成拖慢主线程用户端白屏后来改成纯异步又出现“用户以为上传完成点了提交实际后台还在排队导致订单关联空文件”再后来加了上传状态轮询结果又因未做防抖限频把OSS的ListObjects接口刷崩了……所以这篇内容不讲抽象理论不堆API文档只说一个有十年一线经验的开发者在真实业务压力下如何用最小改动、最稳方案把“上传OSS”这件事真正做成产品级能力。适合所有正在对接对象存储的前端、全栈、后端同学尤其适合那些被“上传失败”工单追着跑的产品技术负责人。2. 核心设计思路拆解同步与异步不是二选一而是分层协作很多人看到标题第一反应是“同步快但阻塞异步不卡但难追踪我该选哪个”——这个提问本身就有陷阱。真正的生产级上传架构从来不是非此即彼的单选题而是一个分层协同的流水线。我把整个上传链路拆成四个明确层级客户端预处理层 → 传输调度层 → 服务端中继层 → 后台任务层。同步与异步的决策点就落在“传输调度层”与“服务端中继层”的交界处而选择依据完全取决于上层业务对“确定性”和“实时性”的要求强度。2.1 四层架构的本质与分工逻辑客户端预处理层这是所有上传的起点负责文件读取、基础校验类型、大小、分辨率、前端压缩图片WebP转换、视频H.264软编码降帧率、分片切分针对大文件。关键原则是能前端做的绝不甩给后端。比如一张5MB的JPG前端用Canvas压缩到200KB再上传OSS流量省60%用户等待时间从8秒降到2秒。这里没有同步/异步之分全是同步JS执行但它是后续所有策略的基石。传输调度层这是同步/异步的“分水岭”。它决定文件是以“请求-响应”模式直传OSS同步还是以“发消息-进队列-后台拉取”模式交由服务端中继异步。选择标准非常朴素该文件是否必须在本次HTTP请求生命周期内完成端到端确认比如用户注册时上传头像后端要立即读取该图片生成URL存入数据库这就是强确定性需求必须同步直传而用户发布一条带3个视频的动态视频转码、封面截图、敏感词检测都需要耗时此时上传只需保证“已接收”后续流程可异步那就走异步中继。服务端中继层这是异步上传的“守门人”。它不直接操作OSS而是接收客户端上传的元数据文件名、大小、临时Token、业务ID生成唯一任务ID存入Redis或数据库并向消息队列如RabbitMQ/Kafka投递任务。它的核心价值是解耦上传动作与业务逻辑提供幂等入口和失败重试兜底。我见过太多项目把OSS SDK直接扔进Spring Boot Controller里结果一个OSS超时就把整个HTTP线程池占满。中继层用极轻量的HTTP接口承接把重活交给后台任务这才是正解。后台任务层这是异步上传的“执行引擎”。它从队列消费任务调用OSS SDK完成实际上传并在完成后触发回调如更新数据库状态、推送WebSocket通知、启动FFmpeg转码。关键设计点在于任务必须支持断点续传、分片重试、失败告警。比如一个2GB视频上传到一半网络中断任务不能直接失败而应记录已上传分片下次拉起时自动跳过已传部分——这需要OSS的Multipart Upload API深度集成不是简单封装SDK就能搞定。提示很多团队误以为“用了消息队列就是异步”结果发现上传接口依然超时。根本原因是没做分层——客户端仍等待中继层返回“任务创建成功”就结束但中继层自己却在同步调用OSS。正确做法是中继层收到请求后立即返回{task_id: xxx, status: queued}然后异步去调用OSS。这样客户端等待时间恒定在50ms内彻底解除阻塞。2.2 同步直传的适用边界与致命陷阱同步直传并非过时方案它在特定场景下仍是首选。我的经验是只要满足以下三个条件中的两个就该优先考虑同步直传文件体积 ≤ 5MB图片/音频常见业务要求“上传即生效”无后续加工环节客户端网络质量可控如企业内网App、PC Web后台。但同步直传有三个极易被忽略的“静默杀手”签名时效性陷阱OSS直传需前端携带Signature而Signature有时效通常15-30分钟。如果用户打开上传页后闲置20分钟再操作签名已失效但前端校验可能只检查了文件类型导致上传时OSS直接返回403。解决方案是前端在上传前发起一次轻量API向后端申请即时签名而非页面加载时一次性获取。浏览器内存溢出Chrome对单次FileReader.readAsArrayBuffer()加载的文件有内存限制约1GB。一个800MB的4K视频前端试图一次性读取会直接崩溃。必须强制分片哪怕同步上传也要走Multipart流程。跨域预检风暴OSS直传需发送OPTIONS预检请求。若同时上传10个文件浏览器会并发发出10个OPTIONSOSS的预检响应头若未正确配置如Access-Control-Allow-Headers: *会导致部分请求被拦截。实测下来必须在OSS Bucket的CORS规则里显式声明Content-Type, Authorization, x-oss-date, x-oss-security-token等头部。2.3 异步中继的不可替代性不只是为了解耦异步中继常被简化为“把活丢给后台”但它的深层价值远不止于此。在我参与的一个在线教育平台项目中异步中继层直接解决了三个同步方案无法攻克的难题统一风控网关所有上传请求先过中继层可在此植入文件病毒扫描调用ClamAV、敏感内容识别调用开源NSFW模型、盗版音频比对音频指纹提取。同步直传把这些逻辑塞进前端或OSS回调要么不安全要么不可控。流量削峰填谷开学季每天有2万学生同时上传课程作业视频。同步方案会让OSS QPS瞬间冲到5000触发云厂商限流。中继层配合Redis分布式锁任务队列积压监控把峰值QPS压到800以内且保证99%任务在5分钟内完成。全链路可观测性中继层记录每个任务的创建时间、客户端IP、User-Agent、文件MD5、预期OSS路径。当用户投诉“视频没上传成功”运维不用翻OSS日志直接查中继数据库5秒定位是客户端未发送、还是任务卡在队列、或是OSS上传超时——这种可追溯性是同步方案永远做不到的。3. 核心细节解析与实操要点图片/音频/视频的差异化处理策略虽然都叫“上传OSS”但图片、音频、视频三类文件在实操中完全是三种物种。它们的处理策略差异直接决定了上传成功率、用户体验和成本开销。下面我以真实项目参数为例逐类拆解关键细节。3.1 图片上传小体积≠低复杂度预处理才是成败关键图片看似最简单却是出问题最多的。原因在于前端兼容性差、格式碎片化、尺寸失控、缩略图需求刚性。我统计过某社交App的图片上传失败日志73%源于“前端Canvas压缩失败”而非网络问题。格式兼容性攻坚用户手机相册里可能有HEICiPhone默认、WEBP安卓新机、AVIFChrome最新支持等格式。OSS虽支持存储但老版本微信、iOS Safari无法直接渲染。解决方案是前端用ffmpeg/ffmpegWASM版或heic2any库在上传前统一转为JPEG。实测heic2any处理1张4MB HEIC图耗时约1.2秒iPhone 12远低于后端转码的3秒延迟且节省了OSS存储费用HEIC比JPEG小40%但转码成本更高。尺寸与分辨率控制用户上传一张12000×8000的RAW图前端不做处理直接上传OSS流量暴涨CDN回源压力剧增。必须在FileReader读取后用Canvas进行等比缩放。关键参数最大宽度/高度设为2000px覆盖99%显示场景压缩质量JPEG设为0.8肉眼无损体积减半缩放算法用ctx.imageSmoothingQuality high避免模糊。这一步耗时约300ms中端安卓机但换来的是OSS上传速度提升3倍。缩略图自动生成业务常需原图大图中图小图四套尺寸。同步方案需前端生成4张再上传内存爆炸。正确做法是前端只传原图中继层生成任务时附带thumbnails: [{width: 1200, height: 800}, {width: 400, height: 300}]参数后台任务用sharp库批量生成并上传。sharp处理1张原图生成4套缩略图仅需400ms且支持流式处理内存占用稳定在20MB内。注意别信“OSS图片处理服务IMG”能解决一切。IMG虽支持?x-oss-processimage/resize,w_400动态缩放但首次访问会触发回源计算CDN缓存未命中时延迟高达2秒。对首屏关键图片如商品主图必须预生成并上传IMG只作备用兜底。3.2 音频上传格式战争与采样率陷阱音频的坑比图片更深——因为开发者常误以为“MP3就是MP3”却不知采样率、比特率、声道数的组合能产生上百种变体。某语音笔记App曾因音频上传失败率飙升至15%排查三天才发现iOS录音API默认输出44.1kHz采样率而Android部分机型是48kHzOSS虽能存但后台语音转文字服务只认44.1kHz导致转写失败。采样率标准化必须在前端或中继层统一重采样。推荐方案前端用Web Audio API的AudioContext创建ScriptProcessorNode或现代AudioWorklet实时重采样。参数固定为采样率44100Hz行业通用标准声道单声道Mono双声道体积翻倍且无增益比特率128kbps平衡音质与体积。实测重采样1分钟MP3耗时约800msWeb Worker中执行不卡UI。格式归一化用户可能上传WAV无损但体积大、AMR运营商定制老旧安卓机、M4A苹果生态。统一转为MP3或OPUS更优。OPUS优势明显同等音质下体积比MP3小30%且支持流式传输。用ffmpeg.wasm转码命令ffmpeg -i input.wav -c:a libopus -b:a 64k -vbr on -compression_level 10 output.opus转码1分钟音频约1.5秒但上传体积从10MBWAV降至1.2MBOPUS。音频元数据清理WAV/MP3常含ID3标签歌手、专辑、封面图这些冗余数据会增大上传体积。用music-metadata库在前端剥离const metadata await parseBlob(file); if (metadata.common.picture) delete metadata.common.picture; // 删除嵌入封面单文件可减少50-200KB积少成多。3.3 视频上传大文件的生存指南与分片艺术视频是上传场景的“珠峰”一个4K视频动辄2GB上传失败率天然高于其他类型。同步方案在这里基本失效必须依赖分片上传Multipart Upload 断点续传 智能重试。分片大小的黄金法则OSS官方建议分片大小100MB但这只是理论值。实测发现移动端4G网络分片设为5MB小分片抗抖动但HTTP请求多家庭WiFi分片设为50MB减少请求数提升吞吐企业内网分片设为100MB最大化单次传输效率。关键是根据网络类型动态调整。前端可用navigator.connection.effectiveTypeChrome支持或简单ping OSS域名测速来判断。分片上传的完整流程以5MB分片为例前端计算文件MD5向中继层发起POST /upload/init携带file_name,file_size,part_size5242880中继层调用OSSInitiateMultipartUpload返回upload_id存入RedisTTL 24h前端循环读取文件分片对每片计算MD5调用PUT /object?uploadIdxxxpartNumber1上传每片上传成功后记录{part_number: 1, etag: xxx}到本地IndexedDB所有分片上传完毕前端发起POST /upload/complete携带所有part_numberetag列表中继层调用OSSCompleteMultipartUpload生成最终URL。断点续传的可靠性保障这是视频上传的灵魂。必须做到分片上传失败时前端不重传整片而是用HEAD /object?uploadIdxxxpartNumber1查询该分片是否已存在OSS返回200则跳过网络中断后恢复前端从IndexedDB读取已传分片列表只续传缺失部分为防本地存储丢失中继层在/upload/init时生成resume_token加密的upload_id文件MD5用户刷新页面后用此token重新拉取已传分片列表。实操心得别用OSS SDK的multipartUpload方法它内部实现过于粗暴分片失败就整个重来。必须手写分片逻辑精确控制每一片的上传、校验、重试。我维护的一个视频上传库已稳定支撑某短视频平台日均30万条视频上传核心就是这套自主分片引擎。4. 实操过程与核心环节实现从零搭建可落地的上传中继服务现在我们把前面所有设计落地为一个可运行的后端服务。这里以Node.jsExpress为例因其轻量、生态成熟、适合快速验证。重点不是教语法而是展示生产环境必须考虑的每一个细节。4.1 服务初始化最小可行架构与依赖选型# 初始化项目 mkdir oss-upload-middleware cd oss-upload-middleware npm init -y npm install express redis bullmq oss-sdk/core sharp ffmpeg.wasmexpress提供HTTP接口足够轻量redis存储上传任务状态、分片信息、防重放Tokenbullmq基于Redis的现代化队列支持重试、优先级、延迟任务比旧版Bull更稳oss-sdk/core阿里云OSS官方SDKv3TypeScript友好API清晰sharp图像处理比GraphicsMagick快5倍内存占用低ffmpeg.wasm前端音视频处理后端无需安装FFmpeg二进制。为什么不用NestJS或Spring Boot因为上传中继的核心诉求是“快进快出”框架越重HTTP延迟越高。实测Express中继层平均响应时间12msNestJS同类实现达45ms——对QPS过千的场景这33ms就是压垮服务器的最后一根稻草。4.2 核心接口实现/upload/init与/upload/complete/upload/init任务创建与分片预分配// routes/upload.ts import { Router } from express; import { v4 as uuidv4 } from uuid; import Redis from redis; import { Queue } from bullmq; const router Router(); const redisClient Redis.createClient(); const uploadQueue new Queue(upload, { connection: redisClient }); router.post(/init, async (req, res) { try { const { file_name, file_size, part_size 5242880, business_id } req.body; // 1. 基础校验 if (!file_name || !file_size || file_size 10 * 1024 * 1024 * 1024) { // 10GB上限 return res.status(400).json({ error: Invalid file params }); } // 2. 生成唯一任务ID与upload_id const task_id task_${uuidv4()}; const upload_id upload_${uuidv4()}; // 3. 调用OSS InitiateMultipartUpload const ossClient new OSS({ region: oss-cn-hangzhou, accessKeyId: process.env.OSS_ACCESS_KEY_ID!, accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET!, bucket: my-bucket }); const result await ossClient.initMultipartUpload({ bucket: my-bucket, object: uploads/${business_id}/${task_id}/${file_name}, headers: { x-oss-server-side-encryption: AES256 } }); // 4. 存储任务元数据Redis Hash await redisClient.hset(upload:${task_id}, { upload_id: result.uploadId, file_name: file_name, file_size: file_size.toString(), part_size: part_size.toString(), status: initialized, created_at: Date.now().toString() }); await redisClient.expire(upload:${task_id}, 24 * 60 * 60); // 24小时过期 // 5. 返回前端所需信息 res.json({ task_id, upload_id: result.uploadId, oss_endpoint: https://my-bucket.oss-cn-hangzhou.aliyuncs.com, oss_bucket: my-bucket, oss_object: result.object, total_parts: Math.ceil(file_size / part_size) }); } catch (err) { console.error(Init upload failed:, err); res.status(500).json({ error: Init failed }); } }); export default router;/upload/complete分片合并与状态终态// routes/upload.ts (续) router.post(/complete, async (req, res) { try { const { task_id, parts } req.body; // parts: [{part_number: 1, etag: xxx}, ...] // 1. 从Redis获取任务信息 const taskData await redisClient.hgetall(upload:${task_id}); if (!taskData.upload_id) { return res.status(404).json({ error: Task not found }); } // 2. 构建OSS Complete参数 const ossClient new OSS({ /* same config */ }); const completeParams { bucket: my-bucket, object: taskData.oss_object, uploadId: taskData.upload_id, parts: parts.map((p: any) ({ partNumber: p.part_number, etag: p.etag })) }; // 3. 调用OSS CompleteMultipartUpload const result await ossClient.completeMultipartUpload(completeParams); // 4. 更新任务状态为completed并存入最终URL await redisClient.hset(upload:${task_id}, { status: completed, final_url: result.url, completed_at: Date.now().toString() }); // 5. 推送任务到后台队列触发后续处理转码、截图等 await uploadQueue.add(process-media, { task_id, final_url: result.url, business_id: taskData.business_id }, { attempts: 3, backoff: { type: exponential, delay: 1000 } }); res.json({ success: true, final_url: result.url, task_id }); } catch (err) { console.error(Complete upload failed:, err); res.status(500).json({ error: Complete failed }); } }); export default router;4.3 后台任务处理器process-media的健壮实现// workers/media-processor.ts import { Worker } from bullmq; import { OSS } from oss-sdk/core; import * as sharp from sharp; import * as fs from fs/promises; const worker new Worker(upload, async (job) { const { task_id, final_url, business_id } job.data; try { // 1. 下载OSS文件到临时目录流式不占内存 const ossClient new OSS({ /* config */ }); const response await ossClient.getObject({ bucket: my-bucket, object: final_url.split(/).slice(-1)[0] // 从URL提取object key }); const tempPath /tmp/${task_id}_${Date.now()}.tmp; await fs.writeFile(tempPath, Buffer.from(await response.content)); // 2. 根据文件类型分发处理 const mimeType await detectMimeType(tempPath); if (mimeType.startsWith(image/)) { await processImage(tempPath, task_id, business_id); } else if (mimeType.startsWith(video/)) { await processVideo(tempPath, task_id, business_id); } else if (mimeType.startsWith(audio/)) { await processAudio(tempPath, task_id, business_id); } // 3. 清理临时文件 await fs.unlink(tempPath); } catch (err) { console.error(Process media ${task_id} failed:, err); throw err; // 触发bullmq重试 } }, { connection: redisClient, concurrency: 5 // 限制并发防OSS打爆 }); // 图片处理生成缩略图 async function processImage(filePath: string, taskId: string, businessId: string) { const buffer await fs.readFile(filePath); const image sharp(buffer); // 生成大图1200px宽 const large await image.resize(1200, null, { withoutEnlargement: true, fit: inside }).jpeg({ quality: 85 }).toBuffer(); // 生成小图200px圆角头像 const small await image.resize(200, 200, { fit: cover, position: center }).png({ compressionLevel: 6 }).toBuffer(); // 上传到OSS const ossClient new OSS({ /* config */ }); await ossClient.putObject({ bucket: my-bucket, object: images/${businessId}/${taskId}_large.jpg, body: large }); await ossClient.putObject({ bucket: my-bucket, object: images/${businessId}/${taskId}_small.png, body: small }); }4.4 前端SDK封装让业务方一行代码接入光有后端不够前端调用必须简单到极致。我封装了一个OssUploader类业务方只需// frontend/upload.ts const uploader new OssUploader({ endpoint: /api/upload, maxRetries: 3, onProgress: (progress: number) updateProgressBar(progress), onSuccess: (result: { final_url: string }) showSuccess(result.final_url) }); // 上传单个文件 uploader.upload(file); // 上传多个文件自动并发控制 uploader.uploadAll([file1, file2, file3]);其核心是分片上传引擎class OssUploader { async upload(file: File) { // 1. 初始化任务 const initRes await fetch(/api/upload/init, { method: POST, body: JSON.stringify({ file_name: file.name, file_size: file.size }) }); const init await initRes.json(); // 2. 分片上传 const partSize parseInt(init.part_size); const totalParts init.total_parts; const uploadedParts: { part_number: number; etag: string }[] []; for (let i 0; i totalParts; i) { const start i * partSize; const end Math.min(start partSize, file.size); const blob file.slice(start, end); // 计算分片MD5用于OSS校验 const md5 await this.calculateMD5(blob); // 上传分片 const partRes await fetch(${init.oss_endpoint}/${init.oss_object}?uploadId${init.upload_id}partNumber${i1}, { method: PUT, headers: { Content-MD5: md5 }, body: blob }); const etag partRes.headers.get(ETag)?.replace(//g, ); uploadedParts.push({ part_number: i1, etag: etag! }); } // 3. 合并分片 const completeRes await fetch(/api/upload/complete, { method: POST, body: JSON.stringify({ task_id: init.task_id, parts: uploadedParts }) }); return completeRes.json(); } }5. 常见问题与排查技巧实录那些文档里不会写的血泪教训再完美的设计也会在真实环境中撞墙。以下是我在多个项目中总结的高频问题、排查路径和独家解决技巧全是“踩过坑之后才懂”的干货。5.1 OSS上传失败的四大元凶与精准定位法现象可能原因快速定位命令解决方案403 Forbidden1. Signature过期2. Bucket CORS未配全3. RAM子账号无oss:PutObject权限curl -I -X OPTIONS https://bucket.oss-region.aliyuncs.com/test.jpg检查OSS控制台CORS设置确保AllowedHeaders包含*或显式列出所需头子账号策略加Action: [oss:PutObject]400 Bad Request1.partNumber非数字或超出范围2.Content-MD5与实际不符3. 分片大小不一致除最后一片openssl md5 file_part_1对比OSS返回ETag前端严格校验partNumber为整数用FileReader读取blob后计算MD5勿用file.slice()后直接MD5503 Service Unavailable1. OSS QPS超限免费版100QPS2. 同一upload_id并发上传分片ossutil ls oss://bucket/ --loglevel warning业务层加令牌桶限流同一任务ID的分片串行上传或用Promise.allSettled控制并发上传后文件损坏1. 前端FileReader.readAsArrayBuffer()未等onload完成就取result2. 分片合并时part_number顺序错乱hexdump -C file_on_osshead -20 对比原始文件实操心得别信OSS控制台的“上传日志”。它只记录成功请求失败请求全在黑洞里。必须在中继层记录全量日志console.log([UPLOAD] ${task_id} ${file_name} ${status} ${error?.message || })并接入ELK。某次线上事故靠这条日志5分钟定位到是某安卓厂商WebView的FileReaderbug而非OSS问题。5.2 视频上传卡死的隐蔽原因与破局点视频上传卡在99%是经典疑难杂症。我梳理出三个反直觉原因浏览器Blob内存泄漏Chrome对new Blob([arrayBuffer])的GC不及时上传大视频时内存飙升到2GB触发浏览器保护机制冻结JS线程。破局点不用Blob改用Uint8Array直接上传。fetch(url, { method: PUT, body: uint8Array })。iOS Safari的XMLHttpRequest超时iOS 15对长时间XMLHttpRequest有强制30秒超时且无法通过timeout属性延长。破局点改用fetchAPI并在分片上传时设置keepalive: true虽不改变超时但能维持连接。OSS分片上传的ListParts隐式调用前端调用CompleteMultipartUpload前OSS会隐式调用ListParts验证分片完整性。若分片过多1000此API可能超时。破局点前端在/upload/init时根据file_size预估分片数若500则主动将part_size翻倍如从5MB→10MB减少分片数。5.3 音频转OPUS后播放无声的终极解法某语音App上线后大量用户反馈“上传的OPUS文件在微信里播放无声”。排查发现微信内置播放器只认opus容器.opus文件不认webm容器即使音频流是OPUS。而ffmpeg.wasm默认输出.webm。错误方案前端用ffmpeg.wasm强行输出.opus——WASM版FFmpeg不支持OPUS容器封装。正确方案后端用flac作为中间格式再用opusenc封装。但更优解是前端不转容器只转编码。用ffmpeg.wasm将输入音频转为libopus编码的webm然后用webm-muxer库在