- 音视频
- 即时通讯
- 通信
【免费下载链接】webrtc
Async-friendly WebRTC implementation in Rust
本文以 examples/play-from-disk-vpx/README.md 为主体,讲解 how to 用 Rust 的 webrtc 库(Async-friendly WebRTC implementation in Rust)把保存在磁盘上的 IVF 视频文件(VP8/VP9 编码)和 OGG 音频文件(Opus 编码)作为媒体源,通过标准 Offer/Answer + 非 Trickle ICE 信令流程实时推送到浏览器播放。读完本文,你将掌握完整的 ffmpeg 造数据命令、示例程序的构建与运行方式(含 Linux/macOS 与 Windows 两套操作)、全部 CLI 参数含义,以及示例源码中媒体引擎配置、SDP 交换、媒体 pacing 与退出时序的源码级实现细节。
play-from-disk-vpx 是什么
play-from-disk-vpx演示了如何把从磁盘读取的 VP8/VP9 视频和/或音频"发送给你的浏览器"。从源码 examples/play-from-disk-vpx/play-from-disk-vpx.rs 的整体结构看,它的媒体管线是:
- 用
IVFReader/OggReader逐帧/逐页解析磁盘文件(来自rtc::media::io模块); - 把解析出的原始帧写入
TrackLocalStaticSample轨道; - 由
PeerConnection完成 Offer/Answer 协商、ICE 连接后,轨道内容被封装成 RTP 包经 UDP 发出; - 浏览器端(通过一个带两个 text-area 和 "Start Session" 按钮的 Web 信令页)接收并播放。
与play-from-disk-h26x(H.264/H.265 的 MP4 源)不同,本示例的输入是IVF 容器(output_vp8.ivf/output_vp9.ivf)和OGG 容器(output.ogg),对应 WebRTC 生态中浏览器端最通用、兼容性最好的 VP8/VP9 + Opus 组合。
第一步:用 ffmpeg 准备媒体文件
原文档给出的造数据命令如下($INPUT_FILE为你任意一个输入媒体文件):
# VP8 视频:IVF 容器,GOP 间隔 30 帧 ffmpeg -i $INPUT_FILE -g 30 output_vp8.ivf # VP9 视频:IVF 容器,显式指定 libvpx-vp9 编码器 ffmpeg -i $INPUT_FILE -g 30 -c libvpx-vp9 output_vp9.ivf # Opus 音频:只取音轨(-map 0:a),双声道(-ac 2),libopus 编码, # page_duration 20000(微秒,即 20ms 一个 Ogg page),-vn 丢弃视频 ffmpeg -i $INPUT_FILE -map 0:a -c:a dca -ac 2 -c:a libopus -page_duration 20000 -vn output.ogg三个参数的取值与源码实现是严格对应的:
-g 30(GOP 间隔):视频是 I 帧周期为 30 帧的序列,保证丢包场景下浏览器有足够多的同步点;- IVF 容器:
stream_video使用IVFReader::new(reader)?解析,它会先读 IVF header 拿到timebase_numerator/timebase_denominator,后面 pacing 就依赖这两个值(见下文); -page_duration 20000:源码中定义了常量const OGG_PAGE_DURATION: Duration = Duration::from_millis(20)(play-from-disk-vpx.rs#L46),stream_audio用一个 20ms 周期的 ticker 来按真实时间轴逐页发送。造文件时若 page 时长与发送节奏差异过大,会造成播放抖动。
原文档要求:生成的
output_vp8.ivf/output_vp9.ivf(以及output.ogg)应放在运行命令指定的路径下,例如rtc/examples/test-data/目录(当前仓库中rtc/是Cargo.toml声明的 path 依赖rtc = { version = "0.21.1", path = "rtc" },若该目录内没有现成的 test-data 文件,直接用上面的 ffmpeg 命令自行生成即可,然后把路径传给-v/-a参数)。
第二步:构建示例
cargo build --example play-from-disk-vpx该 example 已在 Cargo.toml 中注册:
[[example]] name = "play-from-disk-vpx" path = "examples/play-from-disk-vpx/play-from-disk-vpx.rs" bench = false注意示例代码通过#[path = "../common/mod.rs"] mod common;引入共享的运行时辅助模块(examples/common/mod.rs)。该模块的核心设计是:示例与具体 async runtime 解耦——同一份源码可分别跑在runtime-tokio和runtime-smol两个 Cargo feature 之下,common::runtime()通过OnceLock保证进程内只有一个共享的Arc<dyn Runtime>实例,既驱动示例的sleep/interval定时器,也通过PeerConnectionBuilder::with_runtime()注入给连接。默认 feature 为default = ["runtime-tokio", "crypto-ring"],因此默认构建即为 tokio + ring。
第三步:打开 Web 信令页面
按原文档说明,应打开一个带有两个 text-area 和一个 "Start Session" 按钮的信令页面(README 中提供了一个 jsfiddle 在线页面链接,打开后你会看到两个 text-area 和 "Start Session" 按钮)。这个页面只做一件事:在两个文本框之间转发 SessionDescription(base64 编码),充当手动信令通道——上框是浏览器侧、下框是play-from-disk-vpx侧,点击 "Start Session" 后浏览器会执行setRemoteDescription并把 offer 填回上框。
第四步:运行 play-from-disk-vpx(把浏览器的 SDP 作为 stdin 输入)
程序启动后会打印Paste offer from browser and press Enter:并从stdin读取一行 SessionDescription。源码中该逻辑是(play-from-disk-vpx.rs#L292-L302):
// Wait for the offer to be pasted print!("Paste offer from browser and press Enter: "); let line = if cli.input_sdp_file.is_empty() { signal::must_read_stdin()? // 从 stdin 读取一行 } else { fs::read_to_string(&cli.input_sdp_file)? // 或从 -i 指定的文件读取 }; let desc_data = signal::decode(line.as_str())?; // base64 解码(也兼容裸 SDP) let offer = serde_json::from_str::<RTCSessionDescription>(&desc_data)?;由于 stdin 是"一次性通道",原文档给出了三种喂入方式:
Linux/macOS:用管道
把信令页面上框(浏览器侧)的 SessionDescription 复制为环境变量
$BROWSER_SDP,然后运行:echo $BROWSER_SDP | ./target/debug/examples/play-from-disk-vpx -v rtc/examples/test-data/output_vp8.ivf -a rtc/examples/test-data/output.oggVP9 版本加
--vp9开关:echo $BROWSER_SDP | ./target/debug/examples/play-from-disk-vpx -v rtc/examples/test-data/output_vp9.ivf -a rtc/examples/test-data/output.ogg --vp9
Windows:重定向文件
把 SessionDescription 粘贴保存到一个文件(如
my_file);运行:
./target/debug/examples/play-from-disk-vpx -v rtc/examples/test-data/output_vp8.ivf -a rtc/examples/test-data/output.ogg < my_fileVP9 版本:
./target/debug/examples/play-from-disk-vpx -v rtc/examples/test-data/output_vp9.ivf -a rtc/examples/test-data/output.ogg --vp9 < my_file
补充:源码还支持
-i/--input-sdp-file参数直接指定 offer 文件路径(input_sdp_file非空时走fs::read_to_string分支),这是 README 未提及但从 Cli 结构体(#L50-L73) 可以确认的第三种输入方式,对"每次都要重定向 stdin"的场景更方便。
第五步:把 answer 贴回浏览器,开始播放
play-from-disk-vpx处理完 offer 后会向 stdout 打印一行 base64 编码的 answer,把它复制进信令页下框(第二 text-area),然后点击"Start Session"。此时浏览器上方应开始播放视频;文件播完后play-from-disk-vpx会自行退出。
完整 CLI 参数一览
结合源码中的 clap 定义(play-from-disk-vpx.rs#L50-L73),该示例的全部参数如下:
| 参数 | 默认值 | 说明 |
|---|---|---|
-d, --debug | false | 开启 env_logger 详细日志 |
-l, --log-level | INFO | 日志级别(ERROR/WARN/INFO/DEBUG/TRACE) |
-i, --input-sdp-file | ""(空) | 从文件读取 offer;为空则从 stdin 读取 |
-o, --output-log-file | ""(空) | 配合-d,把日志写入文件而非 stdout |
-v, --video <PATH> | 无 | 要推流的视频文件(含 VP8 或 VP9 的.ivf) |
-a, --audio <PATH> | 无 | 要推流的音频文件(.ogg/ Opus) |
--vp9 | false | 视频使用 VP9 而非默认的 VP8 |
-v与-a均为Option,可以只推视频、只推音频或两者都推("video and/or audio")。程序在启动时即校验文件存在性,路径错误会直接报错video file: '{}' not exist/audio file: '{}' not exist退出。
源码解析:SDP 交换与非 Trickle ICE
拿到 offer 后,程序执行标准答案方流程(play-from-disk-vpx.rs#L304-L317):
peer_connection.set_remote_description(offer).await?; let answer = peer_connection.create_answer(None).await?; peer_connection.set_local_description(answer).await?; // Block until ICE Gathering is complete (non-trickle ICE) let _ = gather_complete_rx.recv().await;这里刻意采用非 Trickle ICE:注册的事件处理器Handler实现PeerConnectionEventHandler,在on_ice_gathering_state_change收到RTCIceGatheringState::Complete时通过 channel 通知主流程。主流程阻塞等待该信号,确保local_description()返回的 answer 中已包含全部 ICE candidate,再 base64 编码打印:
if let Some(local_desc) = peer_connection.local_description().await { let json_str = serde_json::to_string(&local_desc)?; let b64 = signal::encode(&json_str); println!("{b64}"); }这样浏览器只需setRemoteDescription一次即可拿到完整候选,简化了手工信令。连接真正建立(RTCPeerConnectionState::Connected)后,connected_tx触发主流程才开始推流——媒体发送严格发生在 ICE 连接成功之后。
此外,PeerConnectionBuilder还做了两件事值得注意(play-from-disk-vpx.rs#L224-L232):
with_udp_addrs(vec![format!("{}:0", signal::get_local_ip())]):把 ICE 收发包的 UDP socket 绑定到本机 IP,避免多网卡环境下 host candidate 混乱;with_runtime(runtime.clone()):显式注入示例共享的运行时(本库无 ambient runtime,所有连接级 I/O 都走注入的&dyn Runtime)。
ICE 服务器配置为公共 STUN:
let config = RTCConfigurationBuilder::new() .with_ice_servers(vec![RTCIceServer { urls: vec!["stun:stun.l.google.com:19302".to_string()], ..Default::default() }]) .build();源码解析:编解码器注册与媒体引擎
MediaEngine决定本端"能协商出哪些编解码器"。示例按 CLI 开关注册 VP8/VP9 与 Opus(play-from-disk-vpx.rs#L160-L198):
let audio_codec = RTCRtpCodecParameters { rtp_codec: RTCRtpCodec { mime_type: MIME_TYPE_OPUS.to_owned(), clock_rate: 48000, // Opus 固定 48kHz channels: 2, .. }, payload_type: 120, .. }; let video_codec = RTCRtpCodecParameters { rtp_codec: RTCRtpCodec { mime_type: if is_vp9 { MIME_TYPE_VP9.to_owned() } else { MIME_TYPE_VP8.to_owned() }, clock_rate: 90000, // 视频固定 90kHz channels: 0, .. }, payload_type: if is_vp9 { 98 } else { 96 }, .. }; if audio_file.is_some() { media_engine.register_codec(audio_codec.clone(), RtpCodecKind::Audio)?; } if video_file.is_some() { media_engine.register_codec(video_codec.clone(), RtpCodecKind::Video)?; } let registry = register_default_interceptors(Registry::new(), &mut media_engine)?;要点:
- 只注册"本次要推的编解码器":
--vp9时 mime 变为 VP9、payload type 从 96 变为 98;不传-a则根本不注册 Opus,answer 里也就不会有音频 m-line,浏览器自然不会等待音轨; - payload type 只是初始提议:真正发包用的 PT 必须取协商结果。
negotiated_payload_type()通过sender.get_parameters()读取协商后的rtp_parameters.codecs.first().payload_type(play-from-disk-vpx.rs#L373-L384),注释明确说明write_sample会把它打到每个包上,而底层write_rtp要求 PT 与协商出的 sender codec 匹配——这是自定义静态轨道示例中最容易踩的坑。
轨道侧,视频/音频分别创建TrackLocalStaticSample并add_track注册进 PeerConnection,轨道元数据(stream id / track id / label)与随机ssrc都写死为webrtc-rs-stream-id-video一类命名。
源码解析:媒体 pacing——为什么用 ticker 而不是 sleep
推流函数stream_video(play-from-disk-vpx.rs#L386-L434)与stream_audio(play-from-disk-vpx.rs#L436-L483)的发送节奏设计是本示例最有复用价值的部分:
视频——发送间隔由 IVF 头部的 timebase 决定,即"每帧应占据的时长":
let sleep_time = Duration::from_millis( ((1000 * header.timebase_numerator) / header.timebase_denominator) as u64, ); let mut ticker = interval(sleep_time); loop { let frame = match ivf.parse_next_frame() { Ok((frame, _)) => frame, Err(err) => { println!("All video frames parsed and sent: {err}"); break; } }; video_track .sample_writer(ssrc, payload_type) .write_sample(&Sample { data: frame.freeze(), duration: Duration::from_secs(1), ..Sample::new(Instant::now()) }) .await?; let _ = ticker.tick().await; }音频——每读出一个 Ogg page,用相邻两个 page 的granule_position差值算出该页包含的采样数(48kHz 采样率),据此得到精确的sample_duration,再以 20ms 的intervalticker 逐页发送:
let sample_count = page_header.granule_position - last_granule; last_granule = page_header.granule_position; let sample_duration = Duration::from_millis(sample_count * 1000 / 48000);源码注释解释了必须使用ticker 而非 sleep的两个原因:
- 避免累积偏差——
time.Sleep不会补偿解析数据花费的时间,逐帧累加下来会越来越慢;ticker 以固定周期唤醒,偏差不会累积; - 绕过 Sleep 本身的延迟抖动问题。
注释同时点出"这不是强制要求,因为帧自带时间戳;但如果一次性把所有帧灌出去,丢包率会高得多"——这正是把本地文件当作"实时信源"播放时必须按真实时间轴 pacing 的原因。
pacing 依赖的interval来自 examples/common/mod.rs,其内部是runtime().interval(period)——即由注入的 Runtime 提供的可等待定时器,这也是示例能做到 runtime 无关的关键。
退出时序:什么时候程序会结束
原文档说"文件播到结尾时play-from-disk-vpx会退出"。对应实现是三路futures::select!(play-from-disk-vpx.rs#L350-L366):
futures::select! { _ = done_rx.recv().fuse() => { println!("received done signal!"); // PeerConnection 进入 Failed } _ = ctrlc_rx.recv().fuse() => { println!("received ctrl-c signal!"); // 用户 Ctrl-C } _ = async { let _ = video_done_rx.recv().await; let _ = audio_done_rx.recv().await; }.fuse() => { println!("All media streaming completed."); // 视频、音频都播完 } } peer_connection.close().await?;三个分支:连接失败(Failed状态由事件处理器转发done_tx)、用户 Ctrl-C(ctrlc::set_handler转发ctrlc_tx)、媒体流全部播完(video/audio 各自的 streaming task 结束后发送 done 信号,注意未启用的那一路会drop(tx)使对应 recv 立即完成)。命中任一分支即close()退出。因此"文件播完自动退出"是常态路径,"Ctrl-C 随时停"是交互路径。
与其他示例的配合
- 反向录制:examples/save-to-disk-vpx/save-to-disk-vpx.rs 与本示例结构几乎镜像——浏览器侧发起 offer 后,它以
Recvonlytransceiver 接收 VP8/VP9 + Opus 并写回 IVF/OGG 文件,适合先save再play的完整往返验证; - H.264/H.265 变体:examples/play-from-disk-h26x/play-from-disk-h26x.rs 演示 MP4 源的同一套 Offer/Answer + 非 Trickle ICE 流程;
- 同系列进阶:
play-from-disk-fec(FlexFEC 前向纠错)、play-from-disk-playlist-control(播放列表控制)、play-from-disk-renegotiation(重协商)、bandwidth-estimation-from-disk(带宽估计)等示例,都是在本文这个"文件 → 轨道 → RTP → 浏览器"骨架上做增量扩展,建议以本文为起点逐个阅读。
小结
play-from-disk-vpx用一个约 500 行的示例,覆盖了 Rust webrtc 库"静态轨道推流"的完整闭环:MediaEngine注册编解码器(VP8/VP9 + Opus,PT 96/98/120)→add_track创建静态轨道 → 从 stdin 或文件读取 base64 offer →set_remote_description/create_answer/set_local_description→ 等待 ICE Gathering 完成(非 Trickle)→ 打印 base64 answer → 连接建立后按 IVF timebase / Ogg granule 用 ticker pacing 逐帧推流 → 播完或 Ctrl-C 后close退出。掌握这套流程后,把示例中的IVFReader/OggReader换成任意实时采集源,即可得到自己的 WebRTC 推流服务端。
- 音视频
- 即时通讯
- 通信
【免费下载链接】webrtc
Async-friendly WebRTC implementation in Rust
相关推荐
Pion WebRTC play-from-disk 实战:用 Go 将本地 IVF/OGG 媒体文件推流到浏览器
Pion WebRTC play from disk 实战:用 Go 将本地 IVF/OGG 媒体文件推流到浏览器 play from disk 是 Pion
WebRTC音视频即时通讯通信webrtc-rs 重协商实战:用 play-from-disk-renegotiation 在已建立的连接上动态增删视频轨
webrtc rs 重协商实战:用 play from disk renegotiation 在已建立的连接上动态增删视频轨 本文围绕 examples/pla
音视频即时通讯通信使用 Pion WebRTC 的 save-to-disk-av1 示例:将浏览器 AV1 视频流实时落盘为 IVF 文件
使用 Pion WebRTC 的 save to disk av1 示例:将浏览器 AV1 视频流实时落盘为 IVF 文件 本指南以仓库中 save to di
WebRTC音视频即时通讯通信
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考