m4s分片下载与合并:从DASH清单到ffmpeg音视频合流

发布时间:2026/10/26 12:58:16
m4s分片下载与合并:从DASH清单到ffmpeg音视频合流

m4s 这个后缀,第一次碰到的人基本都会愣一下:浏览器里明明播得好好的,缓存目录里翻出来的却是一堆几百 KB 到几 MB 不等的小文件,双击打不开,改成 .mp4 照样黑屏。这套 m4s 视频/音频下载方案,讲的就是怎么把这些碎片重新拼回一个完整、能拖进度条的文件。它本质上是 fMP4(fragmented MP4)分片格式的落地产物,属于 DASH 协议体系下最常见的一种切片方式,核心难点集中在两件事:一是找到正确的那一串分片地址,二是搞清楚 init 段和后续分片的拼接顺序。搞明白这两点,前端同学可以把逻辑写成浏览器里的纯 JS 工具,后端同学可以用脚本批量跑,运维同学也能拿它做存档校验。这篇文章面向的读者范围比较宽:会看网络面板就能上手,不需要你懂编解码原理,但如果你想做成自动化工具,文中关于字节偏移和索引解析的部分需要多读两遍。文中所有例子都基于某视频站点的公开播放页面,仅限个人已获授权访问的内容做本地备份用途,遇到加密分片请直接停手。

1. 先把 m4s 是什么搞清楚

很多人第一步就走偏了:以为 m4s 是某种加密格式,或者以为必须装个特殊解码器。实际上它既不神秘也不加密,就是普通的 MP4 盒子结构,只不过被切成了很多段,而且第一段和后面几段承担的角色完全不一样。不把这件事想明白,后面所有拼接操作都是瞎猜。

1.1 从普通 mp4 到分片 mp4 的演变

一个传统的 MP4 文件,内部是一个个 box 首尾相连:ftyp 说明品牌和兼容性,moov 存放全部轨道元信息(时长、轨道数、编码参数、每帧的时间戳和文件偏移),mdat 存放真正的音视频数据。moov 里的 sample table 记录了每一帧在文件中的绝对位置,所以播放器必须先读到 moov 才能开始解码。这也解释了为什么早期网上下载到一半的 MP4 打不开——moov 在文件末尾,没下完就等于什么都没有。

流媒体把这个结构改了。fMP4 把 moov 瘦身成只保留轨道骨架(trak 加 mvex,告诉播放器“后面还会有分片”),把每帧的具体位置信息挪到每个分片自带的 moof 里。于是一个完整的时间线变成:ftyp + moov(这一段叫 init 段,通常几十 KB),然后是无数个 moof + mdat 组合(每个几秒的媒体数据)。播放器先吃 init 建立轨道,再一片一片吃后面的数据,吃到哪播到哪。这个设计对边下边播极其友好,也让 CDN 缓存粒度变得很细。

理解了这个演变,你就能明白为什么单个 m4s 双击打不开:它只是一个没有头的躯干。哪怕它是第一个分片,只要缺了 init 段里的 moov,解码器就不知道这是什么编码、多少采样率、画面多大。所以后面所有方案的核心动作,都是“先拿 init,再按顺序接分片”。

1.2 init 段、分片、索引三件套的分工

实际抓包时你会看到三类东西,作用完全不同,混淆了就会出问题。

第一类是init 段,文件名有时候叫 init.mp4,有时候根本不是一个独立文件,而是主文件的前 N 个字节。它包含 ftyp 和 moov,是整条时间线的“户口本”。

第二类是媒体分片,也就是一堆带序号的 m4s,每片包含 moof(这一片的帧索引)和 mdat(真正的编码数据)。它们必须严格按顺序拼接,顺序错了画面就会花或者时间轴乱跳。

第三类是索引,也就是 sidx box。它不是独立文件,通常藏在主文件的某个字节区间里,内容是一张表:后面每个分片有多大、播多久、是不是关键帧起点。播放器靠它做拖动定位,我们做下载时靠它算 Range。

注意:很多教程只讲“把 init 和分片拼起来”,却不提索引,结果遇到只有一个大 m4s 文件(内部靠 Range 切)的站点就完全不会处理。索引这一环不能跳。

1.3 音视频分离带来的连锁反应

DASH 体系下音轨和视频轨基本是分开的两条流,体现在页面上就是两个 AdaptationSet:一个 mimeType 是 video/mp4,一个是 audio/mp4。它们各自有自己的 init 段、自己的分片序列、自己的时间刻度。这意味着你下载完会得到两份半成品:一份只有画面没声音,一份只有声音没画面。

这不是 bug,是设计。分离的好处是自适应码率可以单独切换音视频,坏处是对下载党来说多了一步合流。合流本身不难,用 ffmpeg 一行命令搞定,但前提是两条流的时间基要对齐。实际遇到过的情况是:视频轨的 timescale 是 90000,音频轨是 44100,两边的分片时长物理上并不完全相等,ffmpeg 合流时会自动处理,但你如果用自己写的脚本按分片序号硬对齐,就会出现音画不同步累积。这一点在后文实操部分会再展开。

2. 三条下载路线怎么挑

搞清楚结构之后,落地方式其实有好几种,代价和适用场景差别很大。我不建议一上来就写脚本,先把最简单的路子走通,确认整个链路没问题,再考虑自动化。下面三条路线按上手难度从低到高排,你可以根据自己的实际需求选。

2.1 路线一:网络面板手工抓取,适合少量文件

打开开发者工具切到网络面板,过滤框里输入 m4s,然后刷新页面开始播放。你会看到请求一条条冒出来,每条都是几百 KB 到几 MB。右键任意一条,选择复制链接地址,就能拿到完整 URL。init 段通常在列表最前面,名字里带 init 或者它的响应体积明显偏小。

这条路的优点是零门槛,缺点是只能对付单个视频,而且很容易漏。因为播放器是懒加载的,你不往后拖进度条,它就不会请求后面的分片。我一般会按住进度条从头拖到尾,把所有分片都逼出来,然后再统一处理。

手工抓取还有个坑:列表是按时间倒序刷新的,你以为的第一条其实是最后请求的。判断顺序要看请求的发起顺序,或者干脆看 URL 里的序号字段——绝大多数站点的分片 URL 里都带数字,比如 seg-1.m4s、seg-2.m4s,也有用零填充的 00001、00002。按数字排一遍就不会错。

2.2 路线二:解析播放器配置批量跑,适合整段内容

页面能播放,说明前端一定拿到了播放地址清单。这个清单通常来自一个接口响应,内容可能是 JSON,也可能是 XML 格式的 MPD 清单。你在网络面板里搜 mpd 或者搜 playurl 之类的关键词,大概率能找到它。

拿到清单后,里面会有 BaseURL、SegmentBase、SegmentTemplate 这些节点。SegmentTemplate 是最省事的一种,它直接给了模板字符串,比如 media 属性写着seg-$Number$.m4s,initialization 属性写着init.mp4,再加一个 startNumber 和分片总数,你就能用循环拼出全部地址。SegmentBase 稍微麻烦一点,它只给你一个大文件的地址和两个字节区间:indexRange 指向索引,Initialization 的 range 指向 init 段,剩下的分片要靠解析索引算出来。

这条路适合写脚本,一次解析、批量下载、自动合并,全程无人值守。我在实际项目里更倾向这种方式,因为参数是结构化的,不容易出错,而且换一个视频只需要改输入地址。

2.3 路线三:前端 MSE 旁路接管,万金油但有代价

有些站点把清单藏得很深,或者做了签名校验,URL 带时效参数,复制出来几分钟就失效。这种情况下可以走 MediaSource Extensions 的路子:在页面里劫持 MediaSource 的实例,在 addSourceBuffer 之后挂上自己的钩子,把每一次 appendBuffer 传进来的 ArrayBuffer 抄一份出来。

原理不复杂。页面的播放器最终都要把这些二进制数据喂给 SourceBuffer,你在中间拦一道,等于把所有分片原样录了下来。按顺序收集完,再按 init 在前的规则拼起来,就是一个完整的文件。

代价也很明显:第一,你必须从头到尾完整播一遍,一个两小时的视频就得挂两小时,而且不能快进太快,否则可能丢片;第二,内存占用不小,得边收边往 IndexedDB 或者本地文件系统里写;第三,这套逻辑和页面结构绑得比较紧,站点改版就可能失效。所以我一般把它当兜底方案,能走路线二就不走这条。

2.4 三条路线的取舍对照

维度手工抓取解析清单批量MSE 旁路录制
上手成本极低中等较高
适合规模1 到 2 个视频任意规模任意规模
是否需要完整播放需要拖完进度条不需要必须完整播
对页面改版的敏感度低中高
可否全自动否可以半自动
主要风险漏片、顺序错参数解析错内存溢出、丢帧

这张表我建议你存下来,下次换个站点先对号入座。经验上,能拿到明文清单的场景占大多数,真正需要动用 MSE 的情况并不多。

3. 从请求里把关键参数抠出来

这一步是整个流程里最考验细心程度的地方。参数抠错一个字节,最后拼出来的文件可能只坏那么一两秒,特别难查。我把几个高频出错点拎出来单独讲。

3.1 认出清单里的 dash 结构

一份典型的 MPD 清单长这样,结构是 MPD 包 Period,Period 包 AdaptationSet,AdaptationSet 包 Representation。视频和音频分别在两个 AdaptationSet 里,每个 Representation 代表一种清晰度。你要做的第一件事是选一路视频、一路音频,记下它们的 id 和带宽。

<MPD type="static" mediaPresentationDuration="PT1H23M45.6S"> <Period> <AdaptationSet mimeType="video/mp4" segmentAlignment="true"> <Representation id="v-1080" bandwidth="2800000" codecs="avc1.640028" width="1920" height="1080"> <BaseURL>https://cdn.example.com/v/1080/main.m4s</BaseURL> <SegmentBase indexRange="863-1023"> <Initialization range="0-862"/> </SegmentBase> </Representation> </AdaptationSet> <AdaptationSet mimeType="audio/mp4"> <Representation id="a-128" bandwidth="128000" codecs="mp4a.40.2"> <BaseURL>https://cdn.example.com/a/128/main.m4s</BaseURL> <SegmentBase indexRange="812-1023"> <Initialization range="0-811"/> </SegmentBase> </Representation> </AdaptationSet> </Period> </MPD>

看这段就能读出很多信息:mediaPresentationDuration是总时长,BaseURL是主文件地址,indexRange="863-1023"表示索引在 863 到 1023 字节之间(含两端),Initialization range="0-862"表示 init 段就是文件开头 0 到 862 字节。这两个区间是紧挨着的,中间没有空隙,这是正常的。

提示:Range 是闭区间,bytes=0-862 一共是 863 个字节,不是 862 个。算偏移量的时候差 1 就会导致解析出来的分片边界全错。

3.2 SegmentBase 与 SegmentTemplate 的拼接逻辑差异

这两种是不同思路,必须分开处理。

SegmentTemplate是“自己拼地址”,清单里给了模板和总时长、单片时长,你算出片数,循环生成 URL。片数算法是ceil(总时长 / 单片时长)。有些清单会给SegmentTimeline,里面用 S 标签列出每一段的实际时长,这种情况直接按 S 标签逐条展开,不要自己算,因为有些分片时长并不均匀。

SegmentBase是“一个文件切着用”,所有数据都在同一个 URL 后面,靠 Range 请求区分。这种模式下的下载其实是发多次 HTTP 请求,每次带不同的 Range 头,拿回不同的字节块,最后按顺序拼起来。听上去麻烦,但对服务器压力小,CDN 命中率高。

还有一种SegmentList,清单里直接把所有分片的 URL 列成一张表,这种最好处理,遍历就行,现实中比较少见。

3.3 字节偏移的计算与 sidx 解析

SegmentBase 模式下绕不开解析索引。sidx box 的结构是固定的:先 4 字节大小、4 字节类型(sidx 的 ASCII),然后是 1 字节 version 加 3 字节 flags,接着 4 字节 timescale、4 或 8 字节 earliest_presentation_time、4 字节 first_offset、2 字节保留位、2 字节 reference_count,最后是 reference_count 条记录,每条 12 字节。

每条记录里,高 1 位是 reference_type(0 表示媒体分片),低 31 位是这一片的字节大小,接下来 4 字节是它的时长,再 4 字节里高 1 位是 starts_with_SAP,低 28 位是 SAP 相关时间。我们真正需要的只有两个数:大小和时长。

function parseSidx(buf) { const dv = new DataView(buf); let p = 0, size = 0; while (p + 8 <= dv.byteLength) { size = dv.getUint32(p); const type = String.fromCharCode( dv.getUint8(p + 4), dv.getUint8(p + 5), dv.getUint8(p + 6), dv.getUint8(p + 7) ); if (type === 'sidx') break; p += size; } let q = p + 8; const version = dv.getUint8(q); q += 4; const timescale = dv.getUint32(q); q += 4; q += version === 0 ? 8 : 16; const firstOffset = dv.getUint32(q); q += 4; q += 2; const count = dv.getUint16(q); q += 2; const refs = []; for (let i = 0; i < count; i++) { const w1 = dv.getUint32(q); q += 4; const dur = dv.getUint32(q); q += 4; q += 4; refs.push({ size: w1 & 0x7fffffff, duration: dur }); } return { timescale, firstOffset, refs }; }

拿到 refs 之后,第一个分片的起始字节是“sidx box 结束位置加上 firstOffset”,也就是p + size + firstOffset。之后每一片依次累加前一片的 size。这个累加过程可以用一个 reduce 写出来:

let cursor = sidxEnd + firstOffset; const ranges = refs.map(r => { const start = cursor; cursor += r.size; return { start, end: cursor - 1 }; });

算完之后对每个区间发一次 Range 请求即可。至于时长,把每片的 duration 加起来除以 timescale 就是总秒数,可以用来做进度估算和完整性校验,很实用。

3.4 请求头里那些容易被忽略的细节

这一步栽过跟头的人应该不少。很多 CDN 会校验 Referer 和 Origin,缺了直接返回 403。标准的做法是把你抓包时看到的请求头原样抄下来,包括 Referer、User-Agent,有些站点还要 Cookie 里的某个字段。

我的习惯是先手工复制一条完整请求到 curl 里跑一遍,确认能跑通再往脚本里搬。curl 里加-H逐条补,补到返回 206 为止。这个“最小可用请求头集合”才是脚本里该带的,多带反而容易触发风控。

还有一个细节:Range 请求如果服务器不支持,会返回 200 加完整文件,而不是 206。这种情况你拿到的其实是整个文件,直接切片存下来就行,别以为失败了。判断依据看响应码和 Content-Range 头有没有 Content-Range。

注意:并发下载分片时,单域名并发数控制在 8 到 16 之间比较稳妥,太高容易被限速或者直接拒连,反而更慢。

4. 实操:把碎片拼成能播的文件

前面都是准备,真正动手就这一步。顺序、方式、工具选对了,几分钟搞定;选错了,排查起来能耗掉一整天。

4.1 二进制拼接的顺序规则与实现

拼接的本质就是把一堆 ArrayBuffer 按顺序塞进同一个字节数组,没有任何转码,纯搬运。顺序必须是 init 段在最前,然后分片 1、分片 2 一路往后。少了 init 段,或者 init 段被放到中间,解码器直接放弃。

命令行下最省事的是cat:

cat init.mp4 seg-1.m4s seg-2.m4s seg-3.m4s > video.mp4

Linux 和 macOS 下都能跑,Windows 的 PowerShell 里用Get-Content -Encoding Byte配-OutFile,或者直接上 ffmpeg 的 concat 协议。

浏览器里没有文件系统,得自己搬字节:

async function concatToBlob(urls, headers) { const chunks = []; let total = 0; for (const url of urls) { const resp = await fetch(url, { headers }); if (!resp.ok) throw new Error(`下载失败 ${url} ${resp.status}`); const ab = await resp.arrayBuffer(); chunks.push(new Uint8Array(ab)); total += ab.byteLength; } const out = new Uint8Array(total); let offset = 0; for (const c of chunks) { out.set(c, offset); offset += c.byteLength; } return new Blob([out], { type: 'video/mp4' }); }

这里有个容易被忽视的点:不要用字符串拼接或者 base64 中转,二进制数据经过编码再解码会出问题。必须是 ArrayBuffer 或者 Uint8Array 直接搬。

4.2 用 ffmpeg 做音视频合流

拼完你会得到两个文件,一个是 video.mp4(只有画面),一个是 audio.mp4(只有声音)。合流命令是这样:

ffmpeg -i video.mp4 -i audio.mp4 \ -map 0:v:0 -map 1:a:0 \ -c copy -movflags +faststart \ output.mp4

-c copy是关键,表示不重新编码,只做容器层面的搬运,速度快到几乎瞬间完成,画质也不会有任何损失。-map指定取哪个输入的第几路流,避免 ffmpeg 自动选择出错。-movflags +faststart把 moov 挪到文件头部,这样文件在网页里能边下边播。

如果你想偷懒,其实可以在拼接阶段就把音视频交错的顺序排好,然后一次性喂给 ffmpeg 的 concat demuxer。但我不推荐这么做,因为音视频分片的时长不一定严格对齐,交错顺序很难写对,出问题的概率远高于分开合流。

顺带说一个参数:如果合流后时长比原片短了一两秒,通常是音频轨比视频轨先结束,加-shortest会被截断,反而不对。正确做法是保持原样,播放器会自动处理尾部静音。

4.3 校验:怎么确认文件真的没坏

拼完别急着删中间文件。先跑一遍 ffprobe:

ffprobe -v error -show_entries stream=index,codec_name,width,height,duration \ -of default=noprint_wrappers=1 output.mp4

正常输出应该能看到两条流,一条 h264 或 hevc,一条 aac,时长接近你预期的总时长(误差通常在 1 秒以内,主要是时间基换算造成的)。如果只看到一条流,说明合流没成功;如果时长明显偏短,说明有分片漏了。

再进一步,用 ffmpeg 做一次快速解码,把画面全解一遍但不输出:

ffmpeg -v error -i output.mp4 -f null -

这条命令会把整个文件解一遍,中间有任何损坏的帧都会报错打出来。一分钟的视频大概几秒钟跑完,两小时的视频也就一两分钟。这是我最推荐的完整性检查手段,比看文件大小靠谱得多。

还有一个土办法:把文件拖进浏览器直接播,从头拖到尾,看有没有卡顿或者花屏。花屏一般说明分片顺序错了,卡顿一般说明某几片下载不完整。

5. 常见问题与排查实录

下面这些问题我在不同站点上基本都碰到过,整理成速查表,遇到直接对号。

5.1 拼完黑屏、无声或花屏

黑屏是最典型的症状,九成是 init 段的问题。要么没下,要么下错了位置。检查方法是看拼接后的文件前几百字节里有没有 ftyp 和 moov 这两个 ASCII 标识,可以用十六进制编辑器瞄一眼,也可以写几行代码搜一下:

const head = new Uint8Array(await blob.slice(0, 65536).arrayBuffer()); const text = new TextDecoder('latin1').decode(head); console.log(text.includes('ftyp'), text.includes('moov'));

两个都 true 才正常。如果 ftyp 有但 moov 没有,说明 init 段偏短,Range 的右边界给少了。

花屏是分片顺序问题,或者是某一片下载中断只存了半截。判断依据:花屏位置往往和某个具体时间点固定对应,反复播都在同一处。这时候把那个时间点附近的分片重新下一遍,比对文件大小,和索引里记录的 size 对不上的就是坏的。

无声比较特殊,常见原因是音轨的 init 段没加进去。音视频是两套独立的 init,合流前必须各自都有完整的头。用 ffprobe 单独查视频文件,如果只有 video 流没有 audio 流,说明当时就没下音轨。

5.2 进度卡住、403 与超时

403 基本都是请求头的问题。先把 Referer 补上,再补 User-Agent,最后看 Cookie。有些站点会对 URL 做签名,签名里含过期时间,这种链接复制出来放一会儿就失效了,必须在页面里当场用。遇到这种情况,要么当场跑脚本,要么走前面的 MSE 录制路线。

卡住不动通常是网络层的,检查两件事:一是响应是不是 200 而不是 206(说明服务器忽略了 Range,返回了整个文件),二是并发是不是太高被限流了。我的经验是并发降到 4 试试,能跑通再慢慢往上加。

超时重试要加,但不能无限重试。设一个上限,比如同一片失败 3 次就跳过并在日志里记下来,等全部跑完再单独补。这样不会因为一片卡死拖垮整个任务。

5.3 看到这些信号就该停手

有些内容加了内容保护,清单里会出现ContentProtection节点,或者分片文件的头部能看到 pssh box。这种是加密流,没有密钥解不开,硬拼出来的文件照样播不了。

判断方法很简单:把 init 段用十六进制查看器打开,搜pssh或者encv、enca这样的字符串。搜到就说明是加密的,直接放弃,不要浪费时间去找什么解密工具。

还有一种情况是清单里明明没有加密标识,但分片下载下来体积明显偏小,拼起来全是马赛克。这可能是站点用了别的保护手段,同样是停手的信号。

提示:只处理你自己有权限访问的内容,加密和付费部分一律不碰。技术上能不能做和该不该做是两回事,这条线要划清楚。

5.4 问题速查表

症状最可能原因排查动作修复方式
打开黑屏无画面init 段缺失或过短搜 ftyp/moov 标识重新按 Range 下 init
特定位置花屏某分片下载不完整比对实际大小与索引 size重下该分片
完全无声音轨 init 未拼入ffprobe 看流数量单独下载音频 init
请求返回 403缺 Referer 或 Cookie用 curl 逐条补头测试补全最小请求头
下载中途卡死并发过高被限流看响应码和耗时降到 4 并发重试
文件能播但时长偏短漏片对比总时长与索引累加值补齐缺失序号
进度条拖不动索引未生效或 moov 在尾部ffprobe 看格式合流时加 faststart

6. 几个实际踩过之后的体会

讲几个文档里不会写的东西。

第一个是文件命名的重要性。分片 URL 里的序号有时是 1 起,有时是 0 起,有时补零到 5 位。我最初偷懒用字符串排序,结果 10 排在了 2 前面,拼出来的视频时间轴整个乱掉。后来统一改成按数字排序,或者干脆按抓取到的响应顺序记一个自增 ID,问题就没了。这个坑看着很蠢,但真的很多人踩。

第二个是中间文件别急着删。我现在的习惯是分片目录、init、合并后的无声视频、音频文件全部留着,等 ffprobe 校验通过再清理。有一次合流出来的文件声音比画面快了半秒,逐段排查发现是音频轨最后少了一个分片,因为那一片的 indexRange 计算用的是另一套偏移基准。如果当时把中间文件删了,整个任务就得从头再来。

第三个是总时长的交叉验证。用清单里的 mediaPresentationDuration 和索引里所有分片时长累加的结果对比,两个值如果差得超过 2 秒,基本可以断定有分片没下全。这一步放在下载完成后、合并之前,能省掉大量返工。

第四个是纯前端方案的落地限制。真要在浏览器里做成工具,最大的瓶颈不是下载速度而是内存。一个两小时的 1080p 视频,两个轨道加起来轻松超过 2 GB,全塞进 ArrayBuffer 会直接把标签页搞崩。解决办法是分块写进 IndexedDB,或者用 File System Access API 直接边下边写到本地磁盘。前者兼容性好但读写慢,后者快但需要用户授权目录。我现在更倾向后者,体验上接近一个原生下载器。

最后提一句扩展方向。这套思路本质上不局限于 m4s,凡是基于分片 + 索引的媒体格式,处理逻辑都差不多:找到清单、解析分片表、按序下载、拼接校验。把这几个环节抽象成独立的模块,换一种格式只需要替换解析器那一层,其余部分可以完全复用。