feat: add WebRTC streaming via str0m + portal session persistence
- Add src/webrtc.rs: HTTP signaling server + str0m Sans-IO WebRTC transport with H.264 Annex-B → RTP packetization and key-frame request handling - avhw: introduce FrameOutput enum (Muxer | Channel) so SwEncState can output to either MP4 muxer or crossbeam channel for WebRTC - cap_portal: support portal session restore tokens (PersistMode::ExplicitlyRevoked) to skip re-authorization dialog; add --no-persist flag to force fresh dialog - args: make --output optional when --port is used for WebRTC mode - state_portal: integrate WebRTC pipeline (encoder channel → RTP forwarding) with shorter GOP for WebRTC (fps/2, min 10) - main: redirect tracing to stderr; validate --output or --port required - Add dependencies: str0m 0.20, serde_json 1, dirs 6
This commit is contained in:
+531
@@ -0,0 +1,531 @@
|
||||
// WebRTC 传输模块 — 使用 str0m (Sans-IO) 将 H.264 编码帧推送到浏览器
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{SocketAddr, TcpListener, UdpSocket};
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use str0m::change::SdpOffer;
|
||||
use str0m::format::Codec;
|
||||
use str0m::media::{Frequency, MediaKind, MediaTime, Mid, Pt};
|
||||
use str0m::net::{Protocol, Receive};
|
||||
use str0m::{Candidate, Event, IceConnectionState, Input, Output, Rtc, RtcConfig};
|
||||
|
||||
// ── 嵌入式 HTML 测试页面 ──────────────────────────────────────────────────
|
||||
|
||||
const HTML_PAGE: &str = r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>wl-webrtc P0</title>
|
||||
<style>body{background:#000;color:#fff;font-family:monospace;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||
video{max-width:90vw;max-height:80vh;border:1px solid #333}
|
||||
#status{margin:12px;font-size:14px;color:#aaa}
|
||||
#debug{position:fixed;bottom:8px;left:8px;font-size:11px;color:#666;max-width:90vw;white-space:pre-wrap}
|
||||
</style></head>
|
||||
<body>
|
||||
<div id="status">Connecting...</div>
|
||||
<video id="video" autoplay playsinline muted></video>
|
||||
<pre id="debug"></pre>
|
||||
<script>
|
||||
const status = document.getElementById('status');
|
||||
const video = document.getElementById('video');
|
||||
const debug = document.getElementById('debug');
|
||||
let pc = null;
|
||||
|
||||
const log = msg => { debug.textContent += msg + '\n'; console.log(msg); };
|
||||
|
||||
function preferH264(sdp) {
|
||||
const lines = sdp.split('\r\n');
|
||||
const h264Pts = lines
|
||||
.filter(line => line.startsWith('a=rtpmap:') && line.toUpperCase().includes('H264/90000'))
|
||||
.map(line => line.match(/^a=rtpmap:(\d+)/)?.[1])
|
||||
.filter(Boolean);
|
||||
if (h264Pts.length === 0) return sdp;
|
||||
return lines.map(line => {
|
||||
if (!line.startsWith('m=video ')) return line;
|
||||
const parts = line.split(' ');
|
||||
const header = parts.slice(0, 3);
|
||||
const pts = parts.slice(3);
|
||||
const preferred = h264Pts.filter(pt => pts.includes(pt));
|
||||
const rest = pts.filter(pt => !preferred.includes(pt));
|
||||
return [...header, ...preferred, ...rest].join(' ');
|
||||
}).join('\r\n');
|
||||
}
|
||||
|
||||
function installStatsLogger(peer) {
|
||||
setInterval(() => {
|
||||
if (peer !== pc) return;
|
||||
const v = video;
|
||||
log(`video: readyState=${v.readyState} currentTime=${v.currentTime.toFixed(2)} ` +
|
||||
`paused=${v.paused} width=${v.videoWidth} height=${v.videoHeight} ` +
|
||||
`srcObject=${v.srcObject ? 'yes' : 'no'}`);
|
||||
peer.getStats().then(stats => {
|
||||
stats.forEach(report => {
|
||||
if (report.type === 'inbound-rtp' && report.kind === 'video') {
|
||||
log(`RTP-in: packetsReceived=${report.packetsReceived} packetsLost=${report.packetsLost} ` +
|
||||
`bytesReceived=${report.bytesReceived} framesDecoded=${report.framesDecoded} ` +
|
||||
`framesDropped=${report.framesDropped} codecId=${report.codecId}`);
|
||||
}
|
||||
if (report.type === 'codec' && report.mimeType && report.mimeType.includes('H264')) {
|
||||
log(`Codec: ${report.mimeType} ${report.payloadType} sdpFmtpLine=${report.sdpFmtpLine}`);
|
||||
}
|
||||
});
|
||||
}).catch(() => {});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (pc) pc.close();
|
||||
pc = new RTCPeerConnection();
|
||||
const peer = pc;
|
||||
|
||||
peer.ontrack = e => {
|
||||
log('ontrack: streams=' + e.streams.length + ' kind=' + e.track.kind);
|
||||
video.srcObject = e.streams[0];
|
||||
status.textContent = 'Track received';
|
||||
};
|
||||
peer.oniceconnectionstatechange = () => {
|
||||
log('ICE: ' + peer.iceConnectionState);
|
||||
status.textContent = 'ICE: ' + peer.iceConnectionState;
|
||||
};
|
||||
|
||||
peer.addTransceiver('video', { direction: 'recvonly' });
|
||||
installStatsLogger(peer);
|
||||
|
||||
peer.createOffer().then(offer => {
|
||||
offer.sdp = preferH264(offer.sdp);
|
||||
return peer.setLocalDescription(offer);
|
||||
})
|
||||
.then(() => new Promise(resolve => {
|
||||
if (peer.iceGatheringState === 'complete') resolve();
|
||||
else peer.onicegatheringstatechange = () => { if (peer.iceGatheringState === 'complete') resolve(); };
|
||||
}))
|
||||
.then(() => fetch('/sdp', { method: 'POST', body: JSON.stringify(peer.localDescription) }))
|
||||
.then(r => { if (!r.ok) throw new Error('SDP exchange failed: ' + r.status); return r.json(); })
|
||||
.then(answer => { if (answer.error) throw new Error(answer.error); return peer.setRemoteDescription(answer); })
|
||||
.then(() => log('SDP answer set'))
|
||||
.catch(e => {
|
||||
status.textContent = 'Error: ' + e.message;
|
||||
log('ERROR: ' + e.message + ' — retrying in 2s...');
|
||||
console.error(e);
|
||||
setTimeout(connect, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body></html>"#;
|
||||
|
||||
// ── WebRTC 状态 ───────────────────────────────────────────────────────────
|
||||
|
||||
pub struct WebRtcState {
|
||||
signal_listener: TcpListener,
|
||||
inner: Option<WebRtcInner>,
|
||||
fps: u32,
|
||||
}
|
||||
|
||||
struct WebRtcInner {
|
||||
rtc: Rtc,
|
||||
socket: UdpSocket,
|
||||
udp_addr: SocketAddr,
|
||||
video_mid: Option<Mid>,
|
||||
video_pt: Option<Pt>,
|
||||
connected: bool,
|
||||
need_keyframe: bool,
|
||||
rtp_clock: u32,
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl WebRtcState {
|
||||
pub fn new(port: u16, fps: u32) -> Result<Self> {
|
||||
let signal_listener = TcpListener::bind(format!("0.0.0.0:{port}"))?;
|
||||
signal_listener.set_nonblocking(true)?;
|
||||
tracing::info!("WebRTC signaling on http://0.0.0.0:{port}/");
|
||||
|
||||
Ok(Self {
|
||||
signal_listener,
|
||||
inner: None,
|
||||
fps,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle_signaling(&mut self) -> Result<bool> {
|
||||
let mut handled = false;
|
||||
loop {
|
||||
let (mut stream, _addr) = match self.signal_listener.accept() {
|
||||
Ok(s) => s,
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||
Err(e) => bail!("TCP accept error: {e}"),
|
||||
};
|
||||
handled = true;
|
||||
stream.set_nonblocking(true)?;
|
||||
|
||||
let mut req = vec![0u8; 65536];
|
||||
let n = match stream.read(&mut req) {
|
||||
Ok(n) => n,
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
|
||||
Err(e) => {
|
||||
tracing::warn!("TCP read error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let req_str = String::from_utf8_lossy(&req[..n]);
|
||||
|
||||
if req_str.starts_with("GET / ")
|
||||
|| req_str.starts_with("GET /sdp ")
|
||||
&& !req_str.contains("Content-Type: application/json")
|
||||
{
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
HTML_PAGE.len(),
|
||||
HTML_PAGE
|
||||
);
|
||||
let _ = stream.write_all(resp.as_bytes());
|
||||
} else if req_str.starts_with("POST /sdp") {
|
||||
let body = extract_body(&req_str);
|
||||
if body.is_empty() {
|
||||
let resp = "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\nempty body";
|
||||
let _ = stream.write_all(resp.as_bytes());
|
||||
continue;
|
||||
}
|
||||
|
||||
match WebRtcInner::new(self.fps)
|
||||
.and_then(|mut new_inner| {
|
||||
let answer_json = new_inner.handle_sdp_offer(body.as_bytes())?;
|
||||
Ok((new_inner, answer_json))
|
||||
}) {
|
||||
Ok((new_inner, answer_json)) => {
|
||||
let replacing = self.inner.is_some();
|
||||
self.inner = Some(new_inner);
|
||||
if replacing {
|
||||
tracing::info!("Replaced WebRTC connection (old dropped)");
|
||||
} else {
|
||||
tracing::info!("New WebRTC connection");
|
||||
}
|
||||
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
answer_json.len(),
|
||||
answer_json
|
||||
);
|
||||
let _ = stream.write_all(resp.as_bytes());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("SDP offer handling failed: {e}");
|
||||
let resp = format!("HTTP/1.1 500 Error\r\nConnection: close\r\n\r\n{e}");
|
||||
let _ = stream.write_all(resp.as_bytes());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let resp = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n";
|
||||
let _ = stream.write_all(resp.as_bytes());
|
||||
}
|
||||
}
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
pub fn poll_rtc(&mut self) -> Result<()> {
|
||||
if let Some(inner) = self.inner.as_mut() {
|
||||
if inner.poll_rtc()? {
|
||||
tracing::warn!("WebRTC connection closed/failed; clearing connection state");
|
||||
self.inner = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn feed_network(&mut self) -> Result<()> {
|
||||
if let Some(inner) = self.inner.as_mut() {
|
||||
inner.feed_network()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn poll_and_feed(&mut self) -> Result<()> {
|
||||
self.poll_rtc()?;
|
||||
self.feed_network()?;
|
||||
self.poll_rtc()
|
||||
}
|
||||
|
||||
pub fn write_h264_frame(&mut self, data: &[u8], frame_number: u64, fps: u32) -> Result<()> {
|
||||
if let Some(inner) = self.inner.as_mut() {
|
||||
inner.write_h264_frame(data, frame_number, fps)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.inner.as_ref().is_some_and(WebRtcInner::is_connected)
|
||||
}
|
||||
}
|
||||
|
||||
impl WebRtcInner {
|
||||
fn new(fps: u32) -> Result<Self> {
|
||||
let _ = fps;
|
||||
let mut rtc = RtcConfig::new().build(Instant::now());
|
||||
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")?;
|
||||
socket.set_nonblocking(true)?;
|
||||
let local_addr = socket.local_addr()?;
|
||||
|
||||
let lan_ip = local_ip().unwrap_or_else(|| {
|
||||
tracing::warn!("Failed to detect LAN IP, falling back to 127.0.0.1");
|
||||
"127.0.0.1".to_string()
|
||||
});
|
||||
let candidate_addr: SocketAddr = format!("{lan_ip}:{}", local_addr.port()).parse()?;
|
||||
let candidate = Candidate::host(candidate_addr, "udp")
|
||||
.map_err(|e| anyhow::anyhow!("candidate: {e}"))?;
|
||||
rtc.add_local_candidate(candidate);
|
||||
tracing::info!("WebRTC UDP: {candidate_addr} (bound 0.0.0.0)");
|
||||
|
||||
Ok(Self {
|
||||
rtc,
|
||||
socket,
|
||||
udp_addr: candidate_addr,
|
||||
video_mid: None,
|
||||
video_pt: None,
|
||||
connected: false,
|
||||
need_keyframe: false,
|
||||
rtp_clock: 0,
|
||||
buf: vec![0u8; 65535],
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_sdp_offer(&mut self, body: &[u8]) -> Result<String> {
|
||||
let offer: SdpOffer = serde_json::from_slice(body)
|
||||
.map_err(|e| anyhow::anyhow!("parse SDP offer: {e}"))?;
|
||||
|
||||
let answer = self
|
||||
.rtc
|
||||
.sdp_api()
|
||||
.accept_offer(offer)
|
||||
.map_err(|e| anyhow::anyhow!("accept_offer: {e}"))?;
|
||||
|
||||
self.need_keyframe = true;
|
||||
tracing::info!("SDP exchange complete, waiting for ICE/DTLS...");
|
||||
|
||||
self.discover_video_params();
|
||||
|
||||
let answer_json =
|
||||
serde_json::to_vec(&answer).map_err(|e| anyhow::anyhow!("serialize answer: {e}"))?;
|
||||
|
||||
String::from_utf8(answer_json).map_err(|e| anyhow::anyhow!("answer utf8: {e}"))
|
||||
}
|
||||
|
||||
fn discover_video_params(&mut self) {
|
||||
for s in ["0", "1", "2", "3"] {
|
||||
let mid: Mid = s.into();
|
||||
if let Some(media) = self.rtc.media(mid) {
|
||||
if media.kind() == MediaKind::Video {
|
||||
tracing::info!("Found video media: mid={mid}");
|
||||
self.video_mid = Some(mid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mid) = self.video_mid {
|
||||
if let Some(writer) = self.rtc.writer(mid) {
|
||||
for pp in writer.payload_params() {
|
||||
tracing::debug!("Codec: pt={:?} spec={:?}", pp.pt(), pp.spec());
|
||||
if pp.spec().codec.is_video() && pp.spec().codec == Codec::H264 {
|
||||
self.video_pt = Some(pp.pt());
|
||||
tracing::info!("H.264 payload type: {:?}", pp.pt());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_rtc(&mut self) -> Result<bool> {
|
||||
loop {
|
||||
match self.rtc.poll_output() {
|
||||
Ok(Output::Transmit(t)) => {
|
||||
tracing::info!("TX {} bytes -> {}", t.contents.len(), t.destination);
|
||||
if let Err(e) = self.socket.send_to(&t.contents, t.destination) {
|
||||
tracing::warn!("UDP send error: {e}");
|
||||
}
|
||||
}
|
||||
Ok(Output::Event(e)) => {
|
||||
tracing::info!("RTC event: {e:?}");
|
||||
match &e {
|
||||
Event::Connected => {
|
||||
tracing::info!("WebRTC connected!");
|
||||
self.connected = true;
|
||||
self.need_keyframe = true;
|
||||
self.discover_video_params();
|
||||
}
|
||||
Event::IceConnectionStateChange(IceConnectionState::Disconnected) => {
|
||||
tracing::warn!("WebRTC disconnected");
|
||||
self.connected = false;
|
||||
}
|
||||
Event::MediaAdded(ma) => {
|
||||
tracing::info!("Media added: mid={:?}", ma.mid);
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("WebRTC event: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Output::Timeout(_t)) => break,
|
||||
Err(e) => {
|
||||
tracing::error!("rtc.poll_output error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn feed_network(&mut self) -> Result<()> {
|
||||
let mut recv_count = 0u32;
|
||||
loop {
|
||||
match self.socket.recv_from(&mut self.buf) {
|
||||
Ok((n, source)) => {
|
||||
recv_count += 1;
|
||||
if recv_count <= 5 {
|
||||
tracing::info!("UDP recv {} bytes from {}", n, source);
|
||||
}
|
||||
let input = Input::Receive(
|
||||
Instant::now(),
|
||||
Receive {
|
||||
proto: Protocol::Udp,
|
||||
source,
|
||||
destination: self.udp_addr,
|
||||
contents: self.buf[..n]
|
||||
.try_into()
|
||||
.map_err(|e| anyhow::anyhow!("receive contents: {e}"))?,
|
||||
},
|
||||
);
|
||||
self.rtc
|
||||
.handle_input(input)
|
||||
.map_err(|e| anyhow::anyhow!("handle_input({n} bytes from {source}): {e}"))?;
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => bail!("UDP recv error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
self.rtc
|
||||
.handle_input(Input::Timeout(Instant::now()))
|
||||
.map_err(|e| anyhow::anyhow!("handle timeout: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_h264_frame(&mut self, data: &[u8], frame_number: u64, fps: u32) -> Result<()> {
|
||||
if !self.connected {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mid = match self.video_mid {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
tracing::warn!("write_h264: no video_mid");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let pt = match self.video_pt {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::warn!("write_h264: no video_pt");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if self.need_keyframe {
|
||||
if !is_idr_nalu(data) {
|
||||
tracing::debug!(
|
||||
"write_h264: skipping non-IDR frame ({} bytes), waiting for keyframe",
|
||||
data.len()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!(
|
||||
"write_h264: got IDR keyframe ({} bytes), starting playback",
|
||||
data.len()
|
||||
);
|
||||
self.need_keyframe = false;
|
||||
}
|
||||
|
||||
let ticks_per_second = 90_000u64;
|
||||
let fps = fps.max(1) as u64;
|
||||
let rtp_timestamp = frame_number.saturating_mul(ticks_per_second) / fps;
|
||||
self.rtp_clock = rtp_timestamp as u32;
|
||||
let rtp_time = MediaTime::new(rtp_timestamp, Frequency::NINETY_KHZ);
|
||||
|
||||
let writer = match self.rtc.writer(mid) {
|
||||
Some(w) => w,
|
||||
None => {
|
||||
tracing::warn!("write_h264: no writer for mid={mid}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
"write_h264: {} bytes, pt={:?}, rtp={}",
|
||||
data.len(),
|
||||
pt,
|
||||
self.rtp_clock
|
||||
);
|
||||
writer
|
||||
.write(pt, Instant::now(), rtp_time, data)
|
||||
.map_err(|e| anyhow::anyhow!("writer.write: {e}"))?;
|
||||
|
||||
self.poll_rtc()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.connected
|
||||
}
|
||||
}
|
||||
|
||||
// ── 工具函数 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// 从 HTTP 请求中提取 body(在 \r\n\r\n 之后)
|
||||
fn extract_body(req: &str) -> &str {
|
||||
if let Some(idx) = req.find("\r\n\r\n") {
|
||||
req.get(idx + 4..).unwrap_or("")
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fn local_ip() -> Option<String> {
|
||||
std::net::UdpSocket::bind("0.0.0.0:0")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.connect("1.1.1.1:80").ok()?;
|
||||
let addr = s.local_addr().ok()?;
|
||||
drop(s);
|
||||
let ip = addr.ip().to_string();
|
||||
if ip == "0.0.0.0" || ip.starts_with("127.") {
|
||||
return None;
|
||||
}
|
||||
Some(ip)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_idr_nalu(data: &[u8]) -> bool {
|
||||
let mut i = 0;
|
||||
while i + 4 < data.len() {
|
||||
if data[i..i + 4] == [0, 0, 0, 1] {
|
||||
let nal_type = data[i + 4] & 0x1F;
|
||||
if nal_type == 5 {
|
||||
return true;
|
||||
}
|
||||
i += 5;
|
||||
} else if i + 3 < data.len() && data[i..i + 3] == [0, 0, 1] {
|
||||
let nal_type = data[i + 3] & 0x1F;
|
||||
if nal_type == 5 {
|
||||
return true;
|
||||
}
|
||||
i += 4;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
Reference in New Issue
Block a user