问题引入:还没解码,mpv 是怎么秒懂轨道的?
当敲下 mpv video.mp4,回车。在第一个画面出现在屏幕上之前,mpv 已经做了一系列精密的初始化:打开文件、探测格式、创建 FFmpeg 上下文、解析轨道------这些在 demux 线程启动之前完成。选轨和建解码器则在 demux 线程启动之后、预读开始之前。
你品品这个细节:mpv 甚至还没开始解码,就已经知道这个文件有几条视频轨、各自的分辨率和帧率、每条音轨的采样率。它是怎么在几百毫秒内完成这些判断的?
一个自然的猜测是:这还不简单,avformat_open_input() 之后,使用 avformat_find_stream_info() 探测流信息、av_find_best_stream() 选轨、stream_component_open() 打开解码器并起独立解码线程。所以粗粒度上,mpv 和 ffplay 是同一副骨架:demux 线程 + 解码线程 + 队列。
那本文还能讲什么?------差距不在流程长短,而在 mpv 把"打开"这个动作拆得更细、更可控 。ffplay 把这一大坨交给了 FFmpeg 黑盒,mpv 却自己搭了一条五层的链,并且让它跑在一个短暂的 Opener 线程 上(§1.1)。全文就一条主线:把"打开"拆成"静态架构"和"动态时序"两条,各看三个设计点:
- 静态架构 (§1-§2):三线程模型、核心数据结构、五层初始化链------重点在
d_thread/d_user这个"用拷贝换锁"的设计(§2.2); - 动态时序 (§3):以 MP4 为例,把"秒开"的真相落回
moov容器------重点在skipinfo这个"用静态表换秒开"的设计(§3.2)。
1. 架构骨架:三线程与数据结构
1.1 三线程模型:Opener 桥接 / Demux 生产者 / 主线程消费者
打开一个文件,涉及的线程是三个,不是两个。先看总架构:
#mermaid-svg-rgGGLbQfE5PmSrEs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rgGGLbQfE5PmSrEs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rgGGLbQfE5PmSrEs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rgGGLbQfE5PmSrEs .error-icon{fill:#552222;}#mermaid-svg-rgGGLbQfE5PmSrEs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rgGGLbQfE5PmSrEs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rgGGLbQfE5PmSrEs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rgGGLbQfE5PmSrEs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rgGGLbQfE5PmSrEs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rgGGLbQfE5PmSrEs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rgGGLbQfE5PmSrEs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rgGGLbQfE5PmSrEs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rgGGLbQfE5PmSrEs .marker.cross{stroke:#333333;}#mermaid-svg-rgGGLbQfE5PmSrEs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rgGGLbQfE5PmSrEs p{margin:0;}#mermaid-svg-rgGGLbQfE5PmSrEs .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-rgGGLbQfE5PmSrEs .cluster-label text{fill:#333;}#mermaid-svg-rgGGLbQfE5PmSrEs .cluster-label span{color:#333;}#mermaid-svg-rgGGLbQfE5PmSrEs .cluster-label span p{background-color:transparent;}#mermaid-svg-rgGGLbQfE5PmSrEs .label text,#mermaid-svg-rgGGLbQfE5PmSrEs span{fill:#333;color:#333;}#mermaid-svg-rgGGLbQfE5PmSrEs .node rect,#mermaid-svg-rgGGLbQfE5PmSrEs .node circle,#mermaid-svg-rgGGLbQfE5PmSrEs .node ellipse,#mermaid-svg-rgGGLbQfE5PmSrEs .node polygon,#mermaid-svg-rgGGLbQfE5PmSrEs .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rgGGLbQfE5PmSrEs .rough-node .label text,#mermaid-svg-rgGGLbQfE5PmSrEs .node .label text,#mermaid-svg-rgGGLbQfE5PmSrEs .image-shape .label,#mermaid-svg-rgGGLbQfE5PmSrEs .icon-shape .label{text-anchor:middle;}#mermaid-svg-rgGGLbQfE5PmSrEs .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rgGGLbQfE5PmSrEs .rough-node .label,#mermaid-svg-rgGGLbQfE5PmSrEs .node .label,#mermaid-svg-rgGGLbQfE5PmSrEs .image-shape .label,#mermaid-svg-rgGGLbQfE5PmSrEs .icon-shape .label{text-align:center;}#mermaid-svg-rgGGLbQfE5PmSrEs .node.clickable{cursor:pointer;}#mermaid-svg-rgGGLbQfE5PmSrEs .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rgGGLbQfE5PmSrEs .arrowheadPath{fill:#333333;}#mermaid-svg-rgGGLbQfE5PmSrEs .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rgGGLbQfE5PmSrEs .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rgGGLbQfE5PmSrEs .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rgGGLbQfE5PmSrEs .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rgGGLbQfE5PmSrEs .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rgGGLbQfE5PmSrEs .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rgGGLbQfE5PmSrEs .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rgGGLbQfE5PmSrEs .cluster text{fill:#333;}#mermaid-svg-rgGGLbQfE5PmSrEs .cluster span{color:#333;}#mermaid-svg-rgGGLbQfE5PmSrEs div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rgGGLbQfE5PmSrEs .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rgGGLbQfE5PmSrEs rect.text{fill:none;stroke-width:0;}#mermaid-svg-rgGGLbQfE5PmSrEs .icon-shape,#mermaid-svg-rgGGLbQfE5PmSrEs .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rgGGLbQfE5PmSrEs .icon-shape p,#mermaid-svg-rgGGLbQfE5PmSrEs .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rgGGLbQfE5PmSrEs .icon-shape .label rect,#mermaid-svg-rgGGLbQfE5PmSrEs .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rgGGLbQfE5PmSrEs .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rgGGLbQfE5PmSrEs .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rgGGLbQfE5PmSrEs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 🔒 demux_internal(in->lock 保护)
🧵 Demux 线程(生产者)
🧵 主线程(消费者)
接管 demuxer → demux_start_thread()
demux_start_prefetch() 启动预读
add_packet_locked()
有新数据
唤醒主循环
demux_read_packet_async()
start_open() 创建 opener 线程
atomic_store(open_done) → 返回 demuxer
🧵 Opener 线程(短暂)
demux_open_url()
stream_create() + demux_open()
play_current_file()
open_demux_reentrant()
f_demux_in filter
f_decoder_wrapper
demux_thread()
read_packet() → desc->read_packet()
demux_lavf / demux_mkv / ...
demux_stream × N
demux_queue\[\] → packet 链表
wakeup_cb → 唤醒主循环
核心设计 :demux 线程是纯生产者(从文件/网络读 packet 塞队列),主线程是纯消费者(通过 filter 取 packet)。两者经由 demux_internal.lock + 条件变量 + wakeup 回调协同。中间的 opener 线程 是短暂的"桥接线程"------它执行耗时的网络 I/O 和格式探测,避免阻塞主线程;完成后通过 atomic_store(&open_done, true) + mp_wakeup_core 通知主线程,随后立即终止。opener 线程终止后才启动 demux 线程,两者无并发。
用一条时序图把三个线程的交接看清楚:
🧵 Demux 线程 🧵 Opener 线程 🧵 主线程 🧵 Demux 线程 🧵 Opener 线程 🧵 主线程 #mermaid-svg-wC1iV19XO2tUKszY{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-wC1iV19XO2tUKszY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wC1iV19XO2tUKszY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wC1iV19XO2tUKszY .error-icon{fill:#552222;}#mermaid-svg-wC1iV19XO2tUKszY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wC1iV19XO2tUKszY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wC1iV19XO2tUKszY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wC1iV19XO2tUKszY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wC1iV19XO2tUKszY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wC1iV19XO2tUKszY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wC1iV19XO2tUKszY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wC1iV19XO2tUKszY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wC1iV19XO2tUKszY .marker.cross{stroke:#333333;}#mermaid-svg-wC1iV19XO2tUKszY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wC1iV19XO2tUKszY p{margin:0;}#mermaid-svg-wC1iV19XO2tUKszY .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-wC1iV19XO2tUKszY text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-wC1iV19XO2tUKszY .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-wC1iV19XO2tUKszY .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-wC1iV19XO2tUKszY .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-wC1iV19XO2tUKszY .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-wC1iV19XO2tUKszY #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-wC1iV19XO2tUKszY .sequenceNumber{fill:white;}#mermaid-svg-wC1iV19XO2tUKszY #sequencenumber{fill:#333;}#mermaid-svg-wC1iV19XO2tUKszY #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-wC1iV19XO2tUKszY .messageText{fill:#333;stroke:none;}#mermaid-svg-wC1iV19XO2tUKszY .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-wC1iV19XO2tUKszY .labelText,#mermaid-svg-wC1iV19XO2tUKszY .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-wC1iV19XO2tUKszY .loopText,#mermaid-svg-wC1iV19XO2tUKszY .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-wC1iV19XO2tUKszY .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-wC1iV19XO2tUKszY .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-wC1iV19XO2tUKszY .noteText,#mermaid-svg-wC1iV19XO2tUKszY .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-wC1iV19XO2tUKszY .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-wC1iV19XO2tUKszY .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-wC1iV19XO2tUKszY .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-wC1iV19XO2tUKszY .actorPopupMenu{position:absolute;}#mermaid-svg-wC1iV19XO2tUKszY .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-wC1iV19XO2tUKszY .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-wC1iV19XO2tUKszY .actor-man circle,#mermaid-svg-wC1iV19XO2tUKszY line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-wC1iV19XO2tUKszY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 阶段 0-2 并行:opener 干活,主线程 mp_idle(§1.3) 阶段 2.5 交接 阶段 3-5 主线程 阶段 6-7 并行 play_current_file() → start_open(url)mp_thread_create(open_demux_thread)while(!open_done) mp_idle()demux_open_url() → stream_create() → demux_open()open_given_type() × N(§2)atomic_store(&open_done, true) + mp_wakeup_core()MP_THREAD_RETURN() → 终止mpctx->>demuxer = open_res_demuxer → cancel_open()demux_start_thread()demuxer_select_track × 2 → 建 decoder/filter/AO/VOdemux_start_prefetch() --- reading=trueread_packet() → add_packet_locked()wakeup_cb() → mp_wakeup_core()demux_read_packet_async() → 解码 → 渲染
把七个阶段压成一张流水线表,作为后面两章的地图(每阶段的关键动作,§2/§3 会展开):
| 阶段 | 线程 | 执行函数 | 关键动作 |
|---|---|---|---|
| 0 | 主线程 | start_open() |
创建 opener 线程,进入 mp_idle 等待 |
| 1 | Opener | demux_open_url → stream_create |
打开底层 I/O(fd / socket) |
| 2 | Opener | demux_open → open_given_type → desc->open |
格式探测 + 建流(§2) |
| 2.5 | Opener→主 | atomic_store(&open_done) + mp_wakeup_core |
交接,opener 终止 |
| 3 | 主线程 | demux_start_thread |
demux 线程启动(reading=false → 休眠) |
| 4 | 主线程 | demuxer_select_track × 2 |
选轨 + 建 decoder/filter/AO/VO |
| 5 | 主线程 | demux_start_prefetch |
reading=true → 唤醒 demux 线程 |
| 6 | Demux | read_packet → add_packet_locked |
生产 packet |
| 7 | 主线程 | run_playloop → 拉帧 → 解码渲染 |
第一帧画面 |
1.2 核心数据结构:一张图读懂
直接背结构体是低效的------先看这一张包含关系图,它比任何代码块都能说明问题(demuxer_t 是门面,demux_internal 才是引擎):
demuxer_t(对外句柄,主线程视角)
├── desc ★ 策略接口(demuxer_desc_t:open/read_packet/seek/close)
├── stream ★ 底层 I/O
├── cancel ★ 取消令牌
├── streams[] / duration / filetype / metadata ← 探测结果
└── in ──▶ demux_internal(真正的引擎)
├── d_thread / d_user ★ 双指针(生产者/消费者,§2.2)
├── lock / wakeup / thread / reading
├── sh_stream[] ← 每条轨道的元信息(codec、分辨率...)
│ └── ds (demux_stream) ← 消费状态(读头、EOF)
│ └── queue ← 当前范围的 packet 链表
├── ranges[] ← seek 后可复用的缓存区间
└── eof / min_secs / max_bytes / seeking ...
(完整字段见 demux/demux.h:224、demux/demux.c:165、demux/demux.c:379,这里只留本文会用到的。)
策略接口 demuxer_desc_t 值得单独看一眼------它是整个解封装层的多态核心(demux/demux.h:133):
c
typedef struct demuxer_desc {
const char *name; // 如 "lavf", "matroska", "rawaudio"
const char *desc; // 如 "libavformat"
int (*open)(struct demuxer *demuxer, enum demux_check check);
bool (*read_packet)(struct demuxer *demuxer, struct demux_packet **pkt);
void (*seek)(struct demuxer *demuxer, double rel_seek_secs, int flags);
void (*close)(struct demuxer *demuxer);
void (*switched_tracks)(struct demuxer *demuxer);
} demuxer_desc_t;
1.3 mp_idle:网络加载时 UI 零冻结的秘密
三线程模型里最容易被低估的是"主线程等待时在做什么"。opener 线程在网络 I/O 上卡住时(可能数秒到数十秒),主线程并不是在 sleep------它在 open_demux_reentrant 的等待循环里跑一个微型事件循环(player/loadfile.c:1351):
c
while (!atomic_load(&mpctx->open_done)) {
mp_idle(mpctx); // ← 不是 sleep!是一个微型事件循环
if (mpctx->stop_play)
mp_abort_playback_async(mpctx);
}
mp_idle(player/playloop.c:1326)每轮迭代执行 11 个步骤:
c
void mp_idle(struct MPContext *mpctx) {
handle_dummy_ticks(mpctx); // 1. 虚拟 tick
handle_clipboard_updates(mpctx); // 2. 剪贴板
mp_wait_events(mpctx); // 3. ★ 阻塞等待 → cond_timedwait
mp_process_input(mpctx); // 4. 处理键盘/鼠标输入(如按 q 退出)
handle_option_callbacks(mpctx); // 5. 选项变更回调
handle_command_updates(mpctx); // 6. 异步命令完成通知
handle_update_cache(mpctx); // 7. 缓存状态
handle_cursor_autohide(mpctx); // 8. 光标自动隐藏
handle_vo_events(mpctx); // 9. 窗口事件(resize/expose/close)
update_osd_msg(mpctx); // 10. OSD 消息
handle_osd_redraw(mpctx); // 11. OSD 重绘
}
mp_wait_events 内部是 mp_dispatch_queue_process → pthread_cond_timedwait。Opener 线程完成时调用 mp_wakeup_core → mp_dispatch_interrupt → 打断 cond_wait → 主线程被唤醒,检查 open_done 发现为 true,跳出循环。
如果没有 mp_idle 的这些步骤 ,在打开网络文件期间(可能耗时数秒到数十秒):窗口会冻结、按 q 无法退出、libmpv 客户端 API 全部阻塞。
2. 五层初始化链:从 URL 到解封装器匹配
从 play_current_file() 到 desc->open() 返回成功,一共五层,全部发生在 opener 线程上(§1.1):
① demux_open_url(url) 打开底层 I/O(stream_t)
② demux_open(s) 挑解封装器(check_levels × demuxer_list,§2.1)
③ open_given_type(desc) 分配 demuxer + d_thread/d_user 双指针(§2.2)
④ desc->open(d_thread) 多态:lavf → demux_open_lavf(§2.3)
⑤ demux_copy(d_user, d_thread) 单向同步,返回 d_user
2.1 探测调度:check_levels × demuxer_list 双层循环
第一层 demux_open_url 职责很单一:把 URL 变成 stream_t(stream 层),挂一个级联的取消令牌,然后全部交给 demux_open(demux/demux.c:3598)。stream_create 内部遍历 stream_list[] 做协议匹配。
第二层 demux_open 是"找对的人做对的事"的调度层(demux/demux.c:3511)。它不解析任何格式,只做一件事------双层循环,第一个成功就返回:
c
// demux/demux.c:3511
static struct demuxer *demux_open(struct stream *stream, ...)
{
const int *check_levels = d_normal; // 默认: NORMAL → UNSAFE 两轮
const struct demuxer_desc *check_desc = NULL;
char *force_format = params ? params->force_format : NULL;
if (force_format && force_format[0]) {
check_levels = d_request; // --demuxer=lavf → 只试 REQUEST 级
if (force_format[0] == '+') { // "+lavf" → FORCE 级
force_format += 1;
check_levels = d_force;
}
check_desc = find_desc_by_name(force_format); // 只试这一个
}
// ★ 双层循环:外层安全门槛,内层能力注册表
for (int pass = 0; check_levels[pass] != -1; pass++) {
enum demux_check level = check_levels[pass];
for (int n = 0; demuxer_list[n]; n++) {
const struct demuxer_desc *desc = demuxer_list[n];
if (!check_desc || desc == check_desc)
demuxer = open_given_type(..., desc, stream, ..., level);
if (demuxer) goto done; // ★ 第一个成功就返回
}
}
done:
return demuxer;
}
外层 check_levels 是一道安全门槛(数值越小越宽松,demux/demux.h:112):
| 等级 | 枚举值 | 语义 | 如何触发 |
|---|---|---|---|
FORCE |
0 | 强制执行:跳过所有检测,直接创建 | --demuxer=+lavf(+ 前缀) |
UNSAFE |
1 | 模糊检测:允许弱特征匹配 | 默认第二轮(NORMAL 全失败后) |
REQUEST |
2 | 用户指定:信任用户对格式的判断 | --demuxer=lavf |
NORMAL |
3 | 安全检测:严格文件头魔数匹配 | 默认第一轮 |
它防止 mpv 把 README.md 错误识别成媒体格式。
内层 demuxer_list 是能力注册表(demux/demux.c:72,12 个基础 + 2 个可选编译项):
c
static const demuxer_desc_t *const demuxer_list[] = {
&demuxer_desc_directory, // 目录 → 自动播放列表
&demuxer_desc_disc, // 光盘(DVD/BD)
&demuxer_desc_edl, // EDL 播放列表
&demuxer_desc_cue, // CUE sheet
&demuxer_desc_rawaudio, // 裸音频流
&demuxer_desc_rawvideo, // 裸视频流
&demuxer_desc_matroska, // MKV / WebM(原生实现)
#if HAVE_LIBARCHIVE // (可选编译项)
&demuxer_desc_libarchive,
#endif
#if HAVE_SUBRANDR // (可选编译项)
&demuxer_desc_sbr,
#endif
&demuxer_desc_lavf, // ★ FFmpeg libavformat(300+ 格式)
&demuxer_desc_mf, // 图片序列
&demuxer_desc_playlist, // 播放列表(m3u/pls)
&demuxer_desc_null, // 空源
&demuxer_desc_mpv, // mpv 内部格式
NULL
};
以本地 video.mp4 为例,执行轨迹是这样的:
pass=0, level=NORMAL:
directory → disc → edl → cue → rawaudio → rawvideo → matroska → lavf ✅
(lavf_check_file 读 ftyp box → score=100 → 第 8 个命中)
前面 7 个 desc 返回 NO_MATCH,lavf 兜底命中;两轮全失败才报错。为什么 mpv 不直接调 FFmpeg?因为解封装器不止 FFmpeg 一家------目录、光盘、播放列表、裸流、原生 MKV 都要共享同一套"探测 → 调度 → 线程包装 → 水位控制"框架。加新格式只需写一个新的 demuxer_desc_t 挂进 demuxer_list[] ,对比 GStreamer 的 GstElementFactory + GstPad 链 + 手动 buffer pool,接入成本更低。
2.2 线程安全的基石:d_thread/d_user 的拷贝与交接
这是亮眼的一处(demux/demux.c:3373)。第三层 open_given_type 的核心动作:
c
static struct demuxer *open_given_type(..., const struct demuxer_desc *desc, ...) {
// ① 分配 demuxer → 绑定策略接口
struct demuxer *demuxer = talloc_ptrtype(NULL, demuxer);
*demuxer = (struct demuxer) {
.desc = desc, // ★ 多态:desc->open 指向具体实现
.stream = stream, // ★ 底层 I/O
.cancel = sinfo->cancel,
...
};
// ② ★★★ 创建 demux_internal + d_thread/d_user 双指针 ★★★
struct demux_internal *in = demuxer->in = talloc_ptrtype(demuxer, in);
*in = (struct demux_internal){
.d_thread = talloc(demuxer, struct demuxer), // ★ 生产者副本
.d_user = demuxer, // ★ 消费者即原对象
...
};
*in->d_thread = *demuxer; // ★ 结构体值拷贝:内容相同,实例不同
// ③ ★ 调用具体解封装器的 open() --- 多态关键(副作用全在 d_thread 上)
int ret = demuxer->desc->open(in->d_thread, check);
if (ret >= 0) {
demux_init_cuesheet(in->d_thread);
demux_copy(in->d_user, in->d_thread); // ★ 单向同步
return demuxer; // ★ 返回的是 d_user(主线程视角)
}
// 失败 → demux_free 回收
}
用一张前后对比表看它的动作(表格按列自适应换行,窄屏也不串位):
❌ 如果没有 d_thread/d_user |
✅ mpv 的实际做法 | |
|---|---|---|
desc->open() 改的是 |
d_user(主线程可见的原对象) |
d_thread(一份副本) |
| 副作用去哪了 | 永久留在主线程可见对象上 | 只落在副本上,主线程看不见 |
| 成功之后 | 无同步,状态已被污染 | demux_copy(d_user, d_thread) 单向同步回来 |
为什么安全? 不是内存隔离,而是时序隔离:
- 初始化期间 :opener 独占
d_thread,主线程在mp_idle挂起(§1.3),没人碰d_user; demux_copy之后 :opener 已终止,主线程接管d_user------两者无并发;- 运行期间 :demux 线程只碰
d_thread,主线程只碰d_user(由in->lock协调)。
严谨一点 :*in->d_thread = *demuxer 是浅拷贝 ------stream、cancel 等指针仍指向同一块内存。所以准确的说法是:
d_thread/d_user实现的是访问职责隔离,而不是彻底的内存隔离。 它不是靠"两份独立数据"安全,而是靠"每一时刻只有一个人碰它"安全。
一个结构体拷贝 + 严格的生命周期交接,换掉了一把初始化锁。
2.3 策略接入:demux_lavf 对 FFmpeg I/O 与取消机制的接管
第四层是 desc->open 的多态落点------对 lavf 就是 demux_open_lavf(demux/demux_lavf.c:1310):
demux_open_lavf(demuxer, check)
├─ lavf_check_file(demuxer, check) // ★ 先探测格式(见 §3)
├─ avformat_alloc_context() // 创建 AVFormatContext
├─ avio_alloc_context(..., mp_read, mp_seek) // ★ 替换 I/O
├─ avfc->interrupt_callback = interrupt_cb // ★ 设置取消回调
├─ avformat_open_input(&avfc, ...) // FFmpeg 解析容器头
├─ if (probeinfo) avformat_find_stream_info(avfc) // §3.2
├─ add_new_streams(demuxer) // AVStream[] → sh_stream[](§3.3)
├─ build_editions(demuxer) // 章节 / 多版本
└─ handle_stream_groups(demuxer) // IAMF / tile grid 等分组
mpv 的 stream 层不能直接被 FFmpeg 用,avio_alloc_context 造三个回调把两者焊在一起(详细实现见上章):
| 回调 | 内部实现 |
|---|---|
mp_read(opaque, buf, size) |
stream_read_partial(((demuxer_t*)opaque)->priv->stream, buf, size) |
mp_seek(opaque, pos, whence) |
stream_seek(...) |
mp_read_seek(opaque, ...) |
stream_seek + stream_read_partial |
取消回调:
c
static int interrupt_cb(void *ctx) {
return mp_cancel_test(((struct demuxer *)ctx)->cancel);
}
这是上一章的 payoff:I/O 的控制权在这一步从 FFmpeg 手里拿回了 mpv 手里 ------mp_read 走环形缓冲区(命中零系统调用),interrupt_cb 走事件驱动取消(按 q 不再靠 100ms 轮询)。第五层 demux_copy 再把探测结果单向同步回 d_user(§2.2)。
⚠️ 这里出现了
read_packet生产 packet、demux_queue队列和水位控制------那是运行时的事,暂时不展开。
2.4 一条链的四次交接
把 §1.1 的三线程和这一节连起来看,整条初始化链有四次跨线程交接,同步机制各不相同:
| 交接 | 生产者 | 消费者 | 同步机制 |
|---|---|---|---|
stream_t |
Opener(stream_create) |
主线程(通过 demuxer->stream) |
Opener 终止后主线程才访问,无并发 |
demuxer_t |
Opener(open_given_type) |
主线程(open_res_demuxer → mpctx->demuxer) |
atomic_store/load(&open_done) + Opener 终止 |
sh_stream[] |
Opener(desc->open 内部 demux_add_sh_stream) |
主线程(demux_copy 后 d_user->streams) |
demux_copy 在 d_thread→d_user 单向同步 |
demux_packet |
Demux 线程(add_packet_locked) |
主线程(demux_read_packet_async) |
in->lock mutex + in->wakeup cond + wakeup_cb |
前三次都发生在 opener 线程终止前,靠"生命周期交接"无锁完成;第四次是运行期的事,才需要 in->lock。
3. 秒开原理:从 MP4 容器特性到 skipinfo 优化
前两章解决了"找对 demuxer、安全初始化、接入 FFmpeg"。现在回答开头的问题:为什么 MP4 不用解码就知道一切参数?
3.1 FFmpeg 信息获取的三阶段模型
很多读者会把 av_probe_input_format2、avformat_open_input、avformat_find_stream_info 混为一谈。它们拿到的信息完全不同:
阶段 返回值 / 填充的字段 I/O 量 获取的信息
──────────────────────────────────────────────────────────────────────────────────
av_probe_input_format2 AVInputFormat* 前几 KB "这是 MP4"(格式名 + score)
(priv->avif = &ff_mov_demuxer) 仅格式名 + 置信度
──────────────────────────────────────────────────────────────────────────────────
avformat_open_input AVFormatContext* 容器头部 codec_id, extradata(avcC/esds),
avfc->streams[].codecpar: 宽高, 采样率, Profile, Level
.codec_id = H264 / AAC + 帧索引(stco/stts/stsz)
.width/height = 1920/1080
.sample_rate = 48000
──────────────────────────────────────────────────────────────────────────────────
avformat_find_stream_info 同一个 avfc(原地修改!) 可能解码数帧 补充 codecpar 的空白字段:
avfc->streams[].codecpar: extradata(FLV/TS 缺)
.extradata ← 从解码帧提取 SPS/PPS 实际宽高、实际帧率
三个关键结论:
priv->avif只是指针,不是数据 。probe 只回答"用哪个工具解析"(&ff_mov_demuxer),工具本身不含 codec 参数,真正的解析发生在avformat_open_input里。find_stream_info是原地修改 。它不创建新结构,而是填补open_input留下的空白字段(FLV 的extradata、实际宽高、帧率等)。- MP4/MKV 在阶段二就拿全了 。
avcC/esds里已编码全部 codec 参数,find_stream_info即便运行,也会发现所有字段非空、直接返回。
表述收敛 :注意上面用的是"可能 解码数帧",不是"必然解码"------find_stream_info 会继续读取 packet 并根据格式补充 codecpar:现代 H.264/AAC FLV 直接从 Sequence Header 提取参数,不触发解码器;MPEG-TS / 老编码才需要真正送入 decoder 解码确认。所以它的代价主要是"额外读取",解码只是其中一种(更贵)的情形。
3.2 为什么 MP4/MKV 不需要 find_stream_info?
lavf_check_file(demux/demux_lavf.c:424)在探测时顺带查一张静态表 format_hacks[](demux/demux_lavf.c:162),其中跟本文主线相关的有两行:
c
{"mp4", .skipinfo = true, .no_pcm_seek = true, .use_stream_ids = true},
{"matroska", .skipinfo = true, .no_pcm_seek = true, .use_stream_ids = true},
(format_hacks 不只管 skipinfo,还承载各格式的兼容性策略------no_pcm_seek、use_stream_ids、HLS 的 no_stream、黑名单 BLACKLIST("tty") 等。本文只看和初始化性能直接相关的 skipinfo。)
决策发生在 demux_open_lavf 里、avformat_open_input 返回之后(demux/demux_lavf.c:1462):
c
bool probeinfo = lavfdopts->probeinfo != 0;
switch (lavfdopts->probeinfo) {
case -2: probeinfo = priv->avfc->nb_streams == 0; break; // 无流才探测
case -1: probeinfo = !priv->format_hack.skipinfo; break; // ★ 默认: 听 format_hack 的
}
if (probeinfo)
avformat_find_stream_info(avfc, NULL);
链路:format_hacks["mp4"].skipinfo=true → probeinfo=false → 跳过 avformat_find_stream_info()。
为什么用静态表,而不是运行时检查 avfc? 两个理由:
- 决策点更早 :
skipinfo在lavf_check_file阶段就定下了,那时avfc还没分配; - "信息完整"很难定义 :
extradata非空 ≠ 信息完整,某些格式仍缺帧率、像素格式。哪些格式头部完整是已知且稳定的事实,一张编译时表比运行时试探可靠------本质是 mpv 开发者对 FFmpeg 各 demuxer 行为的经验编码。
哪些格式不能这么干 (头部不含完整 codec 参数,find_stream_info 得读 packet 甚至解码才能补全):
| 容器 | 头部是否含完整 codec 参数 | find_stream_info 需要做什么 |
|---|---|---|
| MP4 (moov) | ✅ avcC/esds 已含分辨率、Profile、采样率 |
无需(skipinfo=true) |
| MKV/WebM | ✅ CodecPrivate 含完整 codec extradata | 无需(skipinfo=true) |
| FLV (H.264/AAC) | ❌ 缺 codec extradata | 读 Sequence Header 提取,不触发解码器 |
| MPEG-TS | ❌ PAT/PMT 仅 PID 和 stream_type | 需读 + 解码数帧 |
| HLS (m3u8) | ❌ playlist 不含 codec 参数 | 需打开第一片 TS 并解码 |
唯一的例外是 AVIF (demux/demux_lavf.c:516):同为 ISOBMFF 家族,但 alpha 通道、HDR 元数据要解码后才能确定 → 覆盖为 skipinfo=false。
收益量化:
| 场景 | 有 skipinfo |
无 skipinfo |
|---|---|---|
| 额外 I/O | 无 | find_stream_info 可能数十次 av_read_frame |
| 网络预读 | ~moov 大小 | moov + 数帧数据 |
| 首帧延迟 | 仅 open 耗时 | 弱网下可能 +2~10s |
表述收敛 :这里省的是"find_stream_info 带来的额外 读取"------不是 MP4 打开完全没有 I/O。stream_read_peek 探测、avformat_open_input 读 moov 本身就有大量 I/O;只是"不再额外做那一次探测阶段的读取"。
3.3 MP4 实例走读:从 moov 到 sh_stream 的零额外 I/O 路径
抽象讲完了,落回一个具体的 video.mp4(H.264 + AAC),看 MP4 是怎么在阶段二把参数拿全的。
3.3.1 moov:一张自包含的"目录"
video.mp4 的典型布局 (fast-start):
┌──────────────────────────────────────────┐
│ ftyp box │ 文件类型标识 "mp42" │ ← stream_read_peek 读这里(§3.1 阶段一)
├──────────────────────────────────────────┤
│ moov box │ ★ 元数据容器 │
│ ├─ mvhd │ 全局时长、时间标尺 │
│ ├─ trak (视频) │
│ │ └─ mdia/minf/stbl/
│ │ ├─ stsd/avcC │ ★ H.264 SPS+PPS │ ← 分辨率、Profile 全在这
│ │ ├─ stts │ 帧→时间映射 │
│ │ ├─ stss │ 关键帧索引 │
│ │ ├─ stsz │ 每帧大小 │
│ │ └─ stco/co64 │ 每帧在 mdat 的偏移 │
│ └─ trak (音频) │
│ └─ mdia/minf/stbl/
│ └─ stsd/esds │ ★ AAC AudioSpecificConfig │
├──────────────────────────────────────────┤
│ mdat box │ 实际的 H.264/AAC 压缩数据 │
└──────────────────────────────────────────┘
关键点 :moov 是一个自包含的"目录"。只要读到 moov(fast-start 下通常在文件头 1-2MB 内),就拿到了宽、高、Profile、Level、采样率、所有帧的字节偏移(stco)和时间戳(stts)------这正是 find_stream_info 要解码数帧才能拿到的信息,MP4 把它写在头部了。
3.3.2 avformat_open_input 内部:FFmpeg mov demuxer 的 box 解析
当 demux_open_lavf() 调用 avformat_open_input(&avfc, "video.mp4", mov_demuxer, ...) 时,FFmpeg 的 mov demuxer(libavformat/mov.c)做 box 级解析:
avformat_open_input()
│
├─ ① avio_read(pb, 8) → mp_read → stream_read_partial
│ 读取 ftyp box header → "ftyp" + size → 确认是 ISOBMFF
│
├─ ② mov_read_default() 递归解析 box 树
│ ├─ mov_read_mvhd() → avfc->duration = mvhd->duration / timescale
│ ├─ mov_read_trak() × 2 (视频 + 音频)
│ │ └─ mov_read_stsd() ★ 最关键的一步
│ │ ├─ 视频轨: 读到 avcC box
│ │ │ → codecpar->codec_id = H264, extradata = avcC
│ │ │ → codecpar->width = 1920, height = 1080
│ │ │ → codecpar->profile = 100 (High), level = 40 (4.0)
│ │ │ ★ FFmpeg 解析 SPS 自动填充这些字段
│ │ └─ 音频轨: 读到 esds box
│ │ → codecpar->codec_id = AAC, extradata = ASC (2 字节)
│ │ → codecpar->sample_rate = 48000, ch_layout = 2
│ │ ├─ mov_read_stts/stss/stsz/stco → 帧→PTS、关键帧、大小、偏移
│ │ └─ mov_read_udta() → metadata (title, artist, ...)
│ └─ mov_read_mdat() → 记录位置,暂不读内容(读 frame 时才解析)
│
└─ ③ 返回成功,avfc->nb_streams = 2
这段解析期间的全部 I/O :只读了 moov box。fast-start 下 moov 紧跟在 ftyp 之后,一次顺序读取即可全部读入;环形缓冲区(02a 的 128KB buffer)可能只触发一次 fill_buffer → read(fd)。
3.3.3 avcC 里到底有什么
avcC box 的结构(ISO 14496-15):
avcC box:
├── AVCProfileIndication = 100 → High Profile
├── AVCLevelIndication = 40 → Level 4.0
├── lengthSizeMinusOne = 3 → NAL 长度字段 4 字节
├── numOfSequenceParameterSets = 1
│ └── SPS NAL unit data:
│ ├─ profile_idc = 100 → High
│ ├─ level_idc = 40 → 4.0
│ ├─ pic_width_in_mbs = 119 → (119+1)×16 = 1920
│ └─ pic_height_in_map_units = 67 → (67+1)×16 = 1088 → crop 到 1080
└── numOfPictureParameterSets = 1
FFmpeg 的 ff_h264_decode_seq_parameter_set() 在解析 extradata 时自动从 SPS 提取 :width = (pic_width_in_mbs+1)*16 - crop、height、profile、level------全部在 avformat_open_input 阶段零额外 I/O 完成。
(SPS 的位级展开------(119+1)×16=1920、(67+1)×16=1088 再 crop 到 1080,本文不展开。)
3.3.4 但 moov-at-end 怎么办?
注意:
skipinfo解决不了moov不在文件头的问题。如果 MP4 的
moov位于文件末尾(录制工具逐帧写入后最后写 moov),avformat_open_input()本身就要先 seek 到文件尾读moov:
ftyp → mdat(3GB) → moov avio_seek(pb, mdat_end) → 对 HTTP 发 Range 请求 服务器不支持 Range → 只能等 3GB 传完 → 打开极慢 → 读 moov → seek 回 mdat 起点所以网络 MP4 的启动速度,首先取决于容器布局(fast-start),其次才是是否跳过
find_stream_info。这正是-movflags +faststart存在的意义。
3.3.5 AVStream.codecpar → sh_stream 字段映射
avformat_open_input 返回后,demux_open_lavf 调 add_new_streams()(demux/demux_lavf.c:895),把数据从 FFmpeg 结构拷到 mpv 结构:
AVStream.codecpar |
sh_stream.codec |
例(video.mp4) |
|---|---|---|
codec_id |
codec(名称) |
"h264" / "aac" |
width / height |
disp_w / disp_h |
1920 / 1080 |
sample_rate |
samplerate |
48000 |
extradata / extradata_size |
extradata |
avcC / esds 原始字节 |
avg_frame_rate(来自 stts) |
fps |
24.0 |
codec_tag |
codec_tag |
avc1 |
随后 demux_copy(d_user, d_thread) 把这份 sh_stream[] 单向同步到主线程可见的 demuxer_t(§2.2)------主线程拿到第一条 sh_stream 时,初始化就算完成了。
至此,MP4 的初始化全部完成 :lavf_check_file 读 ftyp 前几十字节 → avformat_open_input 读 moov(~100KB-1MB)→ add_new_streams 零 I/O 建 sh_stream → find_stream_info 被跳过 。唯一的风险是损坏的 MP4(avcC 和实际流不一致,极罕见),可用 --demuxer-lavf-probe-info=yes 强制启用验证。
4. 动手验证与设计总结
4.1 命令行日志追踪与验证
调试路线(正文不再散落断点框,统一在此):
open_demux_thread (player/loadfile.c:1217) ← 线程名 "opener"
↓ demux_open (demux/demux.c:3511) ← §2.1 双层循环
↓ open_given_type (demux/demux.c:3373) ← §2.2 d_thread/d_user
↓ demux_open_lavf (demux/demux_lavf.c:1310) ← §2.3 FFmpeg 接入
↓ if (probeinfo) (demux/demux_lavf.c:1469) ← §3.2 skipinfo 决策
↓ add_new_streams (demux/demux_lavf.c:895) ← §3.3 AVStream[] → sh_stream[]
↓ demux_start_thread (demux/demux.c:1203) ← 就绪后
实验 1 → 验证 demuxer_list[] 的匹配过程(对应 §2.1):
bash
mpv --msg-level=demux=trace video.mp4 2>&1 | grep "Trying demuxer\|Detected file format"
你会看到:
[demux] Trying demuxers for level=normal. [demux] Trying demuxer: directory (force-level: normal) [demux] Trying demuxer: disc (force-level: normal) [demux] Trying demuxer: edl (force-level: normal) [demux] Trying demuxer: cue (force-level: normal) [demux] Trying demuxer: rawaudio (force-level: normal) [demux] Trying demuxer: rawvideo (force-level: normal) [demux] Trying demuxer: mkv (force-level: normal) [demux] Trying demuxer: lavf (force-level: normal) ← ★ 第 8 个命中! [demux] Detected file format: mov,mp4,m4a,... (libavformat)
这就是 §2.1 双层循环的真实执行轨迹------前面 7 个 desc 返回 NO_MATCH,lavf 第 8 个命中。Detected file format 这行告诉你 FFmpeg 内部的 probe 结果。如果改用 --demuxer=lavf,输出会变成 force-level: request 且只试 lavf 一个。
实验 2 → 验证 skipinfo 对 find_stream_info 的影响(对应 §3.2):
bash
time mpv --vo=null --ao=null --start=2 --length=1 video.mp4
time mpv --vo=null --ao=null --start=2 --length=1 --demuxer-lavf-probe-info=yes video.mp4
本地 SSD 小文件差距在几毫秒内;网络播放下强制启用 find_stream_info 会多读数帧(弱网下可能 +数秒延迟)。
注意:
find_stream_info是 FFmpeg 内部函数调用,不出现在 mpv/FFmpeg 日志里,无法用 grep"看到"它是否被跳过。skipinfo的证据在源码demux/demux_lavf.c:1462,不在日志。想直接观察,可以在demux/demux_lavf.c:1469的if (probeinfo)设断点,对比video.mp4(跳过)和video.flv(执行)的stream_tell()差异。
4.2 架构哲学:用生命周期交接与拷贝替代加锁
回看全文,mpv 在"打开"这个动作上有三个价值点:
- 策略模式是骨架 (§2.1):
demuxer_desc_t的多态open()让 14 种解封装器共享同一套"探测 → 调度 → 线程包装 → 水位控制"框架; d_thread/d_user是线程安全的基石(§2.2):一个结构体拷贝 + 严格的生命周期交接,换掉了一把初始化锁;skipinfo是秒开的引擎 (§3.2):用一张编译时静态表,把"哪些容器头部信息完整"这个已知事实编码下来,砍掉find_stream_info的额外读取。
如果带走一个设计,那就是:不是所有线程安全问题都需要加锁 ------严格的生命周期交接 + 状态复制(d_thread/d_user),同样可以成为同步手段。什么时候该加锁、什么时候用交接,判据只有一条:共享状态的生命周期窗口是否真的错开了。
这种"用拷贝替代加锁"的哲学在 mpv 里不止出现这一次------mp_frame 在 filter 图里流转也是同样的思路。mpv 的每一个"省",都不是省在实现技巧上,而是省在"想清楚了谁在什么时刻碰什么数据"------想清楚之后,锁自然就少了。
📌 源码锚点:demux/demux.c:3511 (demux_open), demux/demux.c:3373 (open_given_type), demux/demux.c:3598 (demux_open_url), demux/demux.h:133 (demuxer_desc_t), demux/demux_lavf.c:1310 (demux_open_lavf), demux/demux_lavf.c:424 (lavf_check_file), demux/demux_lavf.c:162 (format_hacks), demux/demux_lavf.c:1462 (probeinfo), demux/demux_lavf.c:895 (add_new_streams), player/loadfile.c:1217 (open_demux_thread), player/loadfile.c:1351 (open_demux_reentrant 等待循环), player/playloop.c:1326 (mp_idle)