资讯动态

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

发布时间:2026/10/26 12:58:22 来源:尧图企业网站定制
m4s 这个后缀第一次碰到的人基本都会愣一下浏览器里明明播得好好的缓存目录里翻出来的却是一堆几百 KB 到几 MB 不等的小文件双击打不开改成 .mp4 照样黑屏。这套 m4s 视频/音频下载方案讲的就是怎么把这些碎片重新拼回一个完整、能拖进度条的文件。它本质上是 fMP4fragmented 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$.m4sinitialization 属性写着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 包 PeriodPeriod 包 AdaptationSetAdaptationSet 包 Representation。视频和音频分别在两个 AdaptationSet 里每个 Representation 代表一种清晰度。你要做的第一件事是选一路视频、一路音频记下它们的 id 和带宽。MPD typestatic mediaPresentationDurationPT1H23M45.6S Period AdaptationSet mimeTypevideo/mp4 segmentAlignmenttrue Representation idv-1080 bandwidth2800000 codecsavc1.640028 width1920 height1080 BaseURLhttps://cdn.example.com/v/1080/main.m4s/BaseURL SegmentBase indexRange863-1023 Initialization range0-862/ /SegmentBase /Representation /AdaptationSet AdaptationSet mimeTypeaudio/mp4 Representation ida-128 bandwidth128000 codecsmp4a.40.2 BaseURLhttps://cdn.example.com/a/128/main.m4s/BaseURL SegmentBase indexRange812-1023 Initialization range0-811/ /SegmentBase /Representation /AdaptationSet /Period /MPD看这段就能读出很多信息mediaPresentationDuration是总时长BaseURL是主文件地址indexRange863-1023表示索引在 863 到 1023 字节之间含两端Initialization range0-862表示 init 段就是文件开头 0 到 862 字节。这两个区间是紧挨着的中间没有空隙这是正常的。提示Range 是闭区间bytes0-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_type0 表示媒体分片低 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 段被放到中间解码器直接放弃。命令行下最省事的是catcat init.mp4 seg-1.m4s seg-2.m4s seg-3.m4s video.mp4Linux 和 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 校验怎么确认文件真的没坏拼完别急着删中间文件。先跑一遍 ffprobeffprobe -v error -show_entries streamindex,codec_name,width,height,duration \ -of defaultnoprint_wrappers1 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 看格式合流时加 faststart6. 几个实际踩过之后的体会讲几个文档里不会写的东西。第一个是文件命名的重要性。分片 URL 里的序号有时是 1 起有时是 0 起有时补零到 5 位。我最初偷懒用字符串排序结果 10 排在了 2 前面拼出来的视频时间轴整个乱掉。后来统一改成按数字排序或者干脆按抓取到的响应顺序记一个自增 ID问题就没了。这个坑看着很蠢但真的很多人踩。第二个是中间文件别急着删。我现在的习惯是分片目录、init、合并后的无声视频、音频文件全部留着等 ffprobe 校验通过再清理。有一次合流出来的文件声音比画面快了半秒逐段排查发现是音频轨最后少了一个分片因为那一片的 indexRange 计算用的是另一套偏移基准。如果当时把中间文件删了整个任务就得从头再来。第三个是总时长的交叉验证。用清单里的 mediaPresentationDuration 和索引里所有分片时长累加的结果对比两个值如果差得超过 2 秒基本可以断定有分片没下全。这一步放在下载完成后、合并之前能省掉大量返工。第四个是纯前端方案的落地限制。真要在浏览器里做成工具最大的瓶颈不是下载速度而是内存。一个两小时的 1080p 视频两个轨道加起来轻松超过 2 GB全塞进 ArrayBuffer 会直接把标签页搞崩。解决办法是分块写进 IndexedDB或者用 File System Access API 直接边下边写到本地磁盘。前者兼容性好但读写慢后者快但需要用户授权目录。我现在更倾向后者体验上接近一个原生下载器。最后提一句扩展方向。这套思路本质上不局限于 m4s凡是基于分片 索引的媒体格式处理逻辑都差不多找到清单、解析分片表、按序下载、拼接校验。把这几个环节抽象成独立的模块换一种格式只需要替换解析器那一层其余部分可以完全复用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑