1. 先弄明白 m4s 到底是个什么东西
1.1 前端工程师为什么绕不开 m4s 这套格式
标题里挂着“前端甜点”,那我就从前端的视角切入。这几年做播放器相关的需求,越来越难绕开 m4s 这类文件。原因不复杂:主流的长视频、课程、直播回放类站点,早就从“一个请求拉一个完整 mp4”切换到了分段投递,你打开开发者工具的 Network 面板,类型为 media 的请求会像韭菜一样一茬一茬地冒出来,文件名后缀往往就是 m4s 或者干脆没有后缀,内容是一堆几百 KB 到几 MB 的二进制块。
第一次看到这种场面的人通常会懵:下载下来单个文件双击打不开,放进播放器提示格式不支持,甚至用file命令去看也只告诉你它是 ISO Media。这不是你操作错了,而是这套格式天生就不是给“单个文件直接播”设计的。m4s 本质上是fragmented MP4(简称 fMP4)的一个媒体分片,它必须和同组的一个初始化分片配合才有意义。
我做前端的时候第一次踩这个坑,是想在自研播放器里做一个“本地缓存回放”的功能——把用户看过的片段缓到 IndexedDB 里,下次直接读缓存播放。结果缓存文件单独拿出来怎么都播不了,折腾了两个晚上才想明白:我把 moof + mdat 存下来了,但初始化分片里的 moov 早就被当成一次性资源丢掉了,没有 moov,解码器连编码类型、分辨率、时间刻度都无从得知。
所以这篇内容写给两类人:一类是想搞懂分段 MP4 结构、好写播放器或者做媒体处理工具的前端;另一类是想把自己有权访问的音视频内容做本地归档整理、结果被 m4s 卡住的同学。需要提前说明一句,这里讨论的一切都建立在你对自己有权访问、有权处理的内容进行操作的前提上,涉及到付费墙、加密分片的部分,我在第 4 节会明确说清楚边界在哪、看到什么信号就该停手。
1.2 把任意一个 m4s 拖进十六进制视角看一眼
别急着写代码,先建立直觉。MP4 家族的结构极其规整,整个文件就是一个个“盒子”(box)首尾相接拼起来的。每个盒子的布局固定得不能再固定:
[ 4 字节:盒子总长度 ][ 4 字节:盒子类型 ][ 载荷 … ]盒子类型是四个 ASCII 字符,所以你用任何十六进制编辑器打开一个 m4s,肉眼就能读出结构。常见的几个:
| 盒子类型 | 中文习惯叫法 | 出现在哪 | 作用 |
|---|---|---|---|
ftyp | 文件类型盒 | 初始化分片开头 | 声明品牌与兼容版本 |
moov | 影片元数据盒 | 初始化分片 | 轨道信息、编码参数、时间刻度,全在这 |
styp | 分片类型盒 | 媒体分片开头(部分站点有) | 分片级别的类型声明 |
sidx | 分片索引盒 | 媒体分片 | 分片内各子段的偏移与时长,供播放器做码率切换 |
moof | 影片分片盒 | 媒体分片 | 这一片包含几个采样、每个采样多长多大 |
mdat | 媒体数据盒 | 媒体分片 | 真正的编码数据(H.264/H.265/AAC 裸流) |
mfra | 影片随机访问盒 | 文件末尾偶尔出现 | 快速定位用的索引 |
这里有个关键认知:m4s 不是一种“格式”,它是 fMP4 的一个切片。你把它和信息完整的 mp4 对比,差别只在“元数据放在哪”:普通 mp4 是ftyp + moov + mdat,一次性把整部片子的采样表塞进 moov;fMP4 把 moov 抽出来单独成一个文件(初始化分片),剩下的每个分片变成moof + mdat,moof 里只描述“这一小段”的采样,采样数量通常也就几十到几百个。
这么设计的动机很实际:直播场景下你没法等整部片子录完再写 moov;码率自适应切换也要求播放器能随时从任意一个分片切入。代价就是“文件不再自描述”,必须靠配对关系才能还原。
1.3 初始化分片和媒体分片的分工,别搞混
我见过不少人在这里翻车:手里攒了一堆 m4s,硬拼起来得到一个几 GB 的文件,播放器一打开就报错。问题往往出在把初始化分片当成了普通分片,或者干脆没拿到它。
这两类文件在外观上很像,都是二进制块,但职责完全不同:
- 初始化分片(很多站点命名成
init.mp4、header.m4s、0.m4s):内容主要是ftyp+moov,几乎不含音视频数据,体积通常只有几十 KB 到一两百 KB。它决定了“后面所有分片该怎么被解读”。 - 媒体分片(
seg-1.m4s、1.m4s之类):styp+sidx+moof+mdat,纯数据。
还有一点容易被忽略:音轨和视频轨通常是两条独立的分片序列。也就是说同一个内容,你会看到两套初始化分片、两套编号连续的媒体分片,一套只有画面、一套只有声音。这不是站点故意为难人,而是 DASH 类方案的标准做法——音频码率低、切换粒度可以更粗,视频需要频繁切档,两条流分开管理更灵活。
这就决定了后续的还原路径必然是“先各自拼成一条完整的单轨流,再做音视频合并”,而不是“把所有 m4s 按编号顺序一股脑 cat 到一起”。我在第 3 节会演示正确的拼接顺序,以及为什么直接混拼一定失败。
2. 用 mp4box.js 在浏览器里把盒子读明白
2.1 一个最小可用的解析闭环
前端做这类事情,绕不开 mp4box.js 这个库。它把 MP4 家族的盒子解析封装得相当完整,而且能在浏览器里跑,不需要把文件传到服务端。我一般用它来做两件事:验货(确认拿到的分片序列完整、编码参数正确)和提取时间轴信息(后面合并时对时间戳有用)。
先搭个最小闭环。这里假设你已经通过文件选择器拿到了若干个 ArrayBuffer:
import MP4Box from 'mp4box'; function probeInitSegment(arrayBuffer) { const file = MP4Box.createFile(); return new Promise((resolve, reject) => { file.onReady = (info) => resolve(info); file.onError = (e) => reject(new Error(String(e))); // mp4box.js 要求 ArrayBuffer 上挂一个 fileStart 属性 arrayBuffer.fileStart = 0; file.appendBuffer(arrayBuffer); file.flush(); }); }拿到info之后,关键字段长这样(我用一个典型的视频初始化分片举例):
{ duration: 634520, // 总时长,单位是 timescale timescale: 1000, // 影片级时间刻度,常见是 1000 isFragmented: true, // 这个字段很关键,为 true 说明必须靠分片补齐 tracks: [ { id: 1, codec: "avc1.640028", // H.264 High Profile Level 4.0 timescale: 90000, // 轨道自己的时间刻度 duration: 57106800, nb_samples: 0, // 初始化分片里通常是 0,采样信息在 moof 里 video: { width: 1920, height: 1080 } } ] }isFragmented: true这个字段我建议你每次都打印出来看一眼。它是区分“这个文件能独立播”和“这个文件只是个零件”最直接的判据。另外注意两个 timescale 不是一回事:影片级的是给整体时长用的,轨道级的是给采样时间戳用的,视频轨道常见 90000,音频常见 44100 或 48000。后面算真实秒数一律用时间值 / 轨道 timescale,别拿影片级的去除,除出来的结果会离谱。
codec字符串值得单独说一下,因为你在后续判断兼容性、选转码参数时全靠它。拆开看是avc1 . 64 00 28:64是 Profile(0x64 对应 High),00是约束标志位,28是 Level(0x28 = 40,对应 Level 4.0)。音频常见mp4a.40.2,表示 AAC-LC。如果你看到hvc1或hev1,那是 H.265;看到av01是 AV1;看到vp09是 VP9。这个字符串能直接喂给MediaSource.isTypeSupported()做能力检测,省去一堆猜测。
2.2 逐个分片读 moof,把时间轴拼出来
初始化分片只告诉你“有什么轨道”,具体每个分片包含多少采样、时间偏移多少,得看 moof。mp4box.js 的采样提取接口稍微绕一点,核心是setExtractionOptions加start(),回调里拿onSamples:
function readSegmentSamples(arrayBuffer, trackId) { const file = MP4Box.createFile(); const samples = []; file.onReady = (info) => { file.setExtractionOptions(trackId, null, { nbSamples: 10000 }); file.start(); }; file.onSamples = (id, user, chunk) => { for (const s of chunk) { samples.push({ dts: s.dts, // 解码时间戳 cts: s.cts, // 显示时间戳 duration: s.duration, // 采样时长,单位是轨道 timescale size: s.size, // 采样字节数 is_sync: s.is_sync // 是否关键帧 }); } }; arrayBuffer.fileStart = 0; file.appendBuffer(arrayBuffer); file.flush(); return samples; }注意一个实操细节:要读某个媒体分片的采样,你往往需要先把对应的初始化分片和它拼在一起再喂给解析器,因为 mp4box.js 需要 moov 里的 stsd(采样描述)才能正确解析 mdat 内容。我一般的做法是在内存里做一次new Blob([initBuffer, segmentBuffer]),转成 ArrayBuffer 再喂进去。这一步看起来多此一举,但不做的话你会拿到空数组或者解析异常。
拿到采样列表后能算出很有用的东西。比如判断某一段的首个采样是不是关键帧(is_sync),这直接决定你从这里切入播放会不会花屏。再比如累加duration除以 trackscale,就能算出每个分片的真实时长,和sidx里声明的时长做比对——我遇到过好几次时长对不上的情况,通常是拿到的分片序列中间缺了编号,靠这个比对能提前发现,省得后面拼完才发现中间有一段花屏。
还有一个坑要提:dts和cts不一致时说明存在 B 帧重排序,视频流大量存在这种情况。你做本地播放验证的时候如果发现画面抖动或者时间戳报错,八成是只用了 dts 而丢了 cts。mp4box.js 给的采样信息里两个都在,别只用一半。
2.3 用 WebCodecs 直接验证编码参数,不用等合并
解析出参数之后,前端还有个很方便的验证手段:WebCodecs。你不需要把文件拼完整,直接拿初始化分片里的 codec 字符串和第一个 mdat 里的关键帧数据试一下,看解码器认不认。
async function canDecode(codecString, width, height) { if (!('VideoDecoder' in window)) return false; const support = await VideoDecoder.isConfigSupported({ codec: codecString, codedWidth: width, codedHeight: height, hardwareAcceleration: 'prefer-hardware' }); return support.supported === true; }这段代码的价值在于排查“为什么我在本地播放器里打不开”。有时候真不是文件坏了,而是编码参数超出了你环境的能力范围——比如某些站点用 H.264 High 4:4:4 或者 10bit 的 H.265,浏览器能解析出参数但解不了。先跑一遍能力检测,能把“文件问题”和“环境问题”分得清清楚楚,避免在错误方向上浪费时间。
顺带说一句,如果你打算在浏览器里直接做合并(而不是下载到本地用命令行),WebCodecs +VideoEncoder重封装是一条可行路径,但对新手来说实现复杂度不低,中间涉及时间基转换、关键帧对齐、封装 muxer 的格式拼接。我个人的建议是:浏览器端只做解析、校验、预览和打包下载,真正的合并交给命令行工具。分工明确,出问题的概率低得多。
3. 命令行侧收尾:把碎片拼回能播的文件
3.1 为什么字节拼接比 concat demuxer 更靠谱
拿到了一套完整的初始化分片和有序的媒体分片之后,第一步是拼成单轨文件。这里有两种思路,我强烈推荐第一种:
思路一:直接字节拼接。
# 视频轨:先 init,再按编号顺序追加 { cat init-video.mp4; cat $(ls -v seg-video-*.m4s); } > video-track.mp4 # 音频轨:同理 { cat init-audio.mp4; cat $(ls -v seg-audio-*.m4s); } > audio-track.mp4思路二:用 ffmpeg 的 concat demuxer。
# filelist.txt 里逐行写文件路径 ffmpeg -f concat -safe 0 -i filelist.txt -c copy merged.mp4为什么推荐思路一?因为 fMP4 这套格式从设计上就是为字节级追加而生的。每个moof + mdat分片在生成时就保证了拼在前一个后面能够被顺序解析,这也是播放器一边拉流一边往 SourceBuffer 里追加的底层原理。你只是用 shell 把播放器在内存里做的事复现了一遍。而 concat demuxer 是为“多个完整的、自带元数据的文件”设计的,面对一堆没有 moov 的裸分片,它经常报moov atom not found,或者干脆拼出一条时间轴错乱的流。
ls -v这个参数别省。ls seg*.m4s默认是按字典序排的,seg-10.m4s会排在seg-2.m4s前面,拼出来的结果画面能放出鬼畜效果。-v是自然排序,数字按数值大小比。如果你的文件名里带的是变长的数字,或者混了字母,用sort -V也一样:
cat $(ls seg-*.m4s | sort -V) >> video-track.mp4拼接完先别急着往下走,做一次体检:
ffprobe -v error -show_format -show_streams video-track.mp4看三样东西:duration是不是接近你预期的时长、nb_frames或者streams里的轨道数是不是对、有没有start_time明显偏离 0。如果 duration 短了一大截,大概率是中间缺了分片;如果 ffprobe 直接报错,多半是初始化分片的轨道和媒体分片不匹配(比如拿错了码率档位的 init)。
3.2 音视频合并时的参数选择,每一个都有理由
两条单轨流都拼好了,合并这一步反而简单,但参数细节决定成败:
ffmpeg -i video-track.mp4 -i audio-track.mp4 \ -map 0:v:0 -map 1:a:0 \ -c copy \ -movflags +faststart \ -avoid_negative_ts make_zero \ output.mp4逐个解释为什么这么写:
-map 0:v:0 -map 1:a:0:显式指定取第一个输入的视频流、第二个输入的音频流。不加-map的话 ffmpeg 会自己挑,在输入文件轨道数量不确定时容易挑错,尤其是某些分片里夹着时间码轨或者字幕轨的情况。-c copy:纯封装不重编码。这一步的意义是“只改容器不改数据”,速度极快(几个 GB 的文件几秒钟搞定),画质零损失。前提是两条流的编码和封装格式本来就兼容,fMP4 拆出来的 H.264/AAC 完全满足。-movflags +faststart:把 moov 挪到文件开头。不加也能播,但如果你要把文件放到网页上边下边播、或者在某些播放器里拖动进度条,没有 faststart 会等到整段加载完才开始。这是个体积不变、收益很大的参数。-avoid_negative_ts make_zero:这行是解决“开头一小段播放异常”的关键。fMP4 分片的时间戳是基于 baseMediaDecodeTime 递增的,某些站点的第一个分片时间戳不从 0 开始,合并后会出现负的初始时间戳,导致部分播放器开头几帧黑屏或者直接报错。这个参数会把负时间戳整体平移归零,保留相对关系不变。
合并完再跑一次ffprobe,重点看两条流各自的start_time。理想情况下两者都接近 0 且彼此差距在几十毫秒以内。如果视频 start_time 是 0、音频是 0.7 秒,那你播的时候会发现开头耳光声比画面早出——这种情况在后面 3.4 节展开。
3.3 时间轴对不齐的几类典型症状与处理
音画不同步是最常见的后遗症,但成因有好几种,症状也不一样,得分开处理。
症状一:全程稳定偏移,声音总是早或晚一个固定值。
这是最容易修的。先量出偏移量,再用-itsoffset修正:
# 声音比画面早 0.4 秒,就给音频输入加 +0.4 的偏移 ffmpeg -i video-track.mp4 -itsoffset 0.4 -i audio-track.mp4 \ -map 0:v:0 -map 1:a:0 -c copy output.mp4注意-itsoffset必须写在对应输入文件的前面,写后面不生效——这是个新手常犯的顺序错误,我见过好几次有人抱怨参数没效果,其实只是位置放错了。
症状二:越往后越不同步,偏差随时间累积。
这就不是偏移问题了,是两条流的时长不一致。比如视频 600.0 秒、音频 599.4 秒,你强行合并之后,播放器会按较短的那条对齐,剩下 0.6 秒的画面没声音。成因通常是某一侧的最后一个分片不完整,或者拼接时漏了尾部分片。解决办法只有一个:回去核对分片数量。如果你在 Network 面板里记录到视频有 120 个分片、音频有 120 个分片,而以会话结束时实际只拿到了 118 个,那就是抓取过程被打断了,不是合并环节的问题。
症状三:开头一两秒有杂音或者爆音,之后正常。
这是音频编码器的 priming(预热采样)导致的。AAC 编码在开头会多出若干采样用于编解码器对齐,fMP4 里通常用 edit list 声明“跳过前 N 个采样”,但你在字节拼接的时候,如果初始化分片里的 edit list 信息不完整,这个跳过指令就丢了。处理方式是重编码音频前几帧,或者直接接受这一小段瑕疵——说实话,对于个人归档用途,我一般选择接受,因为修它需要重编码,得不偿失。
提示:动手改时间戳之前,先把原始文件备份一份。ffmpeg 的
-c copy虽然无损,但反复多次封装容易把元数据搅乱,一旦改坏了很难回头。
3.4 我在实测中撞到的几个翻车现场
说点文档里不会写的。
翻车一:混拼了不同码率档位的分片。有一次我偷懒,用通配符把所有 m4s 一把梭拼进去,结果出来的文件时长翻倍、画面在两种清晰度之间来回跳。原因是浏览器在播放过程中做了码率自适应,前半段用的是 1080p 分片,网络波动后切到了 720p,两套分片的编号是各自独立的。教训是:拼接前必须按 init 分片分组,一个 init 对应一套分片,不同 init 的绝对不能混。判断方法很简单,看请求路径里的码率标识或者分辨率标识,或者对每个 init 分别跑一遍ffprobe比对codec和分辨率。
翻车二:以?开头的查询参数导致文件重名。有些分片的 URL 路径部分是一样的,靠查询参数区分。你保存的时候如果只取路径最后一段做文件名,会互相覆盖,最后只剩一个文件被反复写入。我现在的习惯是保存时手动编号,并且记一份清单文件(序号 + 原始 URL + 文件大小),出问题时能快速定位是哪个环节丢了数据。
翻车三:把.m4s后缀当成“必须转换”的信号。其实很多情况下你只需要改个名。如果拿到的是完整的单轨流(init + 全部分片拼好),直接mv video-track.mp4就能被大多数播放器识别,因为内容就是标准 MP4。后缀只是站点起的名字,和真实格式无关。反过来说,如果你拿到的是单独的媒体分片,改成什么后缀都没用——缺 moov 就是缺 moov,改名解决不了。
翻车四:在 Windows 上用 PowerShell 做字节拼接踩的坑。如果你在 Windows 上不用 Git Bash 而用 PowerShell,cat a b > c这套写法会把文件当文本处理,二进制内容会被破坏。得用cmd /c copy /b init.mp4+seg1.m4s+seg2.m4s out.mp4,或者干脆用 Python 写三行脚本按二进制读写。我现在的通用做法是一个小 Python 脚本,跨平台一劳永逸:
from pathlib import Path def concat_binary(parts, out_path): with open(out_path, 'wb') as w: for p in parts: with open(p, 'rb') as r: while chunk := r.read(1024 * 1024): w.write(chunk) init = Path('init-video.mp4') segs = sorted(Path('.').glob('seg-video-*.m4s'), key=lambda p: int(p.stem.split('-')[-1])) concat_binary([init, *segs], 'video-track.mp4')这个脚本比 shell 一行流的好处是:排序逻辑显式可控、内存占用恒定(1MB 缓冲)、跨平台行为一致。文件多的时候(几百个分片)速度差别不大,但可控性高很多。
4. 哪些 m4s 你根本不该动,以及为什么
4.1 加密分片的识别信号:看到这些就停手
前面所有操作都有一个隐含前提:分片里装的是明文的编码数据。但现实中你一定会遇到另一类情况——分片拖进解析器,codec字段显示成encv或enca,而不是avc1或mp4a。
这两个前缀的意思是“加密的视频/音频”。看到它们,说明这条流受内容保护机制约束,解密所需的密钥不在你能拿到的分片里。这时候继续折腾拼接是没有任何意义的:你拼出来的文件字节上是完整的,但解码器打开就是一帧黑屏或者直接报错。
判断方法很直接,解密前后的结构差异在 mp4box.js 的解析结果里一眼可见:
| 观察点 | 明文分片 | 受保护分片 |
|---|---|---|
| track.codec | avc1.640028 | encv |
采样描述中是否有sinf盒 | 无 | 有 |
是否有pssh盒 | 无 | 常有 |
| 拼接后能否本地播放 | 能 | 不能 |
我在第 1 节说过,这套方案面向的是“自己有权访问的内容”。看到encv/enca/sinf这几个信号,正确的动作是停下来。原因不只是技术上的(没有密钥确实做不了),更是边界上的:这类内容通常有明确的使用约定,绕过它既不合规也没有实际意义。我写这篇东西的目的是讲清楚分段容器格式的结构,帮你在合法范围内处理自己的素材,不是教人突破什么。
顺带说一个常被误解的点:“能加载到分片”不等于“分片是明文”。播放器能播是因为它拿到了密钥并完成了本地解密,这个密钥的获取过程有它自己的约束条件,和你抓到了几个 m4s 文件是两件完全不同的事。混淆这两件事是新手最容易出的认知偏差。
4.2 码率档位怎么挑,别盲目选最高
假设你面对的是一套明文的、自己有权处理的素材,接下来一个很实际的问题是:同一份内容往往提供多个码率档位,选哪个?
很多人的直觉是“选最高的准没错”。实测下来这个直觉经常是错的,原因有几个:
第一,总分片数直接影响出错概率。1080p 档可能切成 300 个分片,480p 档只有 100 个。分片越多,任何一个环节(网络中断、文件保存冲突、排序出错)出问题的概率就越高。如果是课程、讲座这类以内容信息为主的素材,480p 甚至 360p 完全够用,省下来的时间精力是实打实的。
第二,高分档的编码参数可能超出你的播放环境能力。前面提到的avc1.640028是 Level 4.0,如果你的目标设备是几年前的旧机器,硬解可能不支持;而avc1.4d401f(Main Profile Level 3.1)的兼容性要好得多。选档位之前先跑一次 2.3 节的 WebCodecs 能力检测,比自己瞎猜靠谱。
第三,很多站点的“最高清”是动态码率(VBR),分片大小极不均匀。我见过一段静态画面用了 200KB 的分片、一段高速运动画面用了 8MB 的分片。这种情况下拼接本身没问题,但如果你打算做转码或者上传到别的地方,不均匀的码率会让后续处理变麻烦。我的做法是先把各分片大小列出来看一眼分布,如果最大最小差二十倍以上,就降一档。
具体到操作上,我一般按这个顺序决策:
- 先列出所有可用档位的分辨率和 codec 字符串;
- 用
VideoDecoder.isConfigSupported()过滤掉环境不支持的; - 在剩下的里选分片数量最少的那个不低于目标清晰度的档位;
- 下载完成后先拼一个前 30 秒的片段做快速验证,确认能播再下全量。
第 4 步是我个人最推荐的省时技巧。全量下载可能要几十分钟,而验证前 30 秒只需要拼 3 到 5 个分片,几十秒就能确认整条链路(init 是否正确、编码是否兼容、时间轴是否正常)没问题。等全下完才发现 init 拿错了,那个心情是真的糟糕。
4.3 一条更稳的替代路径:本地播放验证先行
如果你只是想“把自己有权看的内容在本地也能看”,其实有一件事比下载合并更值得先做:在浏览器里用 MediaSource 直接把分片喂进去播放。这件事在合规性和技术上都是最干净的——你不落地任何文件,只是复用播放器本身的解码链路。
核心逻辑和字节拼接是一回事,只是把“写文件”换成了“追加到 SourceBuffer”:
const ms = new MediaSource(); video.src = URL.createObjectURL(ms); ms.addEventListener('sourceopen', async () => { const sb = ms.addSourceBuffer('video/mp4; codecs="avc1.640028"'); for (const seg of orderedSegments) { const buf = await fetch(seg).then(r => r.arrayBuffer()); await new Promise(res => { sb.addEventListener('updateend', res, { once: true }); sb.appendBuffer(buf); }); } ms.endOfStream(); });注意codecs="avc1.640028"这个字符串必须和初始化分片里的 codec 完全一致,写错了addSourceBuffer直接抛异常。这也是为什么第 2 节要先解析初始化分片——那个字符串不是猜出来的,是读出来的。
这条路的好处是:不需要 ffmpeg,不需要处理音视频合并,浏览器自己会把两条流同步好。缺点是不能脱离浏览器,也没法分享。所以我的实际工作流是:先用这条路验证分片序列完整、编码可用,确认无误后再走命令行路径做归档。验证成本近乎为零,但能挡掉绝大部分低级错误。
5. 把它做成小工具时的工程细节
5.1 解析放 Worker 里,别堵主线程
如果你不满足于手动操作、想把解析和拼接做成一个小工具,第一个要处理的工程问题就是主线程阻塞。一个初始化分片加几十个媒体分片,全都用 mp4box.js 解析一遍,在主线程上跑能卡住界面好几秒,用户点什么都像卡死了。
解法是把解析逻辑整体挪进 Web Worker:
// worker.js importScripts('./mp4box.all.min.js'); self.onmessage = (e) => { const { id, buffer, trackId } = e.data; const file = MP4Box.createFile(); const result = { id, samples: [], trackInfo: null }; file.onReady = (info) => { result.trackInfo = info.tracks.find(t => t.id === trackId) || null; file.setExtractionOptions(trackId, null, { nbSamples: 100000 }); file.start(); }; file.onSamples = (tid, user, chunk) => { for (const s of chunk) { result.samples.push({ dts: s.dts, cts: s.cts, duration: s.duration, size: s.size, sync: s.is_sync }); } }; buffer.fileStart = 0; file.appendBuffer(buffer); file.flush(); self.postMessage(result); };主线程只负责读文件和收结果。注意Transferable的用法——把 ArrayBuffer 用转移的方式传给 Worker(postMessage(data, [buffer])),可以避免结构化克隆带来的整块内存复制。几十个分片下来,这个优化能省下不少时间和内存峰值。
还有一个内存上的注意点:如果你解析完还要把分片内容拼成下载链接,用Blob数组而不是把 ArrayBuffer 一个个拼成一个巨型 Uint8Array。new Blob([b1, b2, b3])在浏览器内部是引用式的,不会立刻把所有数据复制到一块连续内存里;而Uint8Array拼接是实打实的内存复制,几个 GB 的内容很容易把标签页跑崩。我踩过这个坑,表现是标签页毫无征兆地白屏,控制台什么都不报,排查了半天才反应过来是内存爆了。
5.2 进度反馈与失败重试,比功能本身更重要
做这类工具,功能跑通大概占三成工作量,剩下七成都是可靠性。因为整个过程涉及网络请求、文件读写、格式解析三类失败源,任何一个环节断掉都会让用户不知所措。
进度反馈要做到分片粒度,而不是“总进度百分比”这种模糊的东西。我一般维护一个状态表:
| 状态 | 含义 | 界面表现 |
|---|---|---|
| pending | 已入队未请求 | 灰色 |
| fetching | 请求中 | 进度条 |
| verifying | 已下载待校验 | 转圈 |
| done | 校验通过 | 绿色勾 |
| failed | 请求或校验失败 | 红色,可点击重试 |
| skipped | 主动跳过的码率档位 | 半透明 |
关键在于verifying这一步——下载成功不代表数据有效。我在 2.2 节提到过,初始化分片和媒体分片是可以做时长比对的,这就是个很好的校验点:拿分片实际解析出来的采样总时长,和sidx里声明的时长比,差值超过一帧就标红。多花这一秒做校验,能在最后拼接前就把问题暴露出来,比拼完几 GB 才发现中间断了一段要好太多。
重试策略上,我倾向于做法保守一点:单个分片最多重试 3 次,间隔指数退避,全部失败就整体停下并保留已完成的进度。不要做成“跳过失败继续往下走”的模式,因为媒体分片是有序的,跳一个就意味着后面全废。宁可停在那里等用户决定,也不要产出一个看起来完整实则中间断裂的文件。
最后再分享一个小技巧。如果你要处理的是一系列同类型内容,与其每次都手动重复整个流程,不如把“档位探测、分片清单、字节拼接、合并校验”四步写成一个脚本,把中间产物(分片清单 JSON、init 文件指纹、拼接后的单轨流)都落盘。这样一旦某一步出问题,你可以从中间任意一步重跑,而不是从零开始。我最早做这件事的时候没有落盘,某次一个 40 分钟的内容在最后合并阶段失败,不得不从头再来一遍,那次之后就老老实实把中间状态全存下来了。