WebRTC 本地视频文件推流 Demo 搭建指南 (Ubuntu 24.04)

文章目录

WebRTC 本地视频文件推流 Demo 搭建指南 (Ubuntu 24.04)

概述

本指南将在 Ubuntu 24.04 上搭建一个基于 libdatachannel(C++ WebRTC 库)的本地视频文件推流系统:

  • 服务端:C++ 程序,通过 FFmpeg 子进程将视频打包为 RTP 流,经 UDP 接收后转发到 WebRTC,内嵌 HTTP 信令服务
  • 客户端:任意设备的浏览器,实时接收并播放视频流
  • 信令方式:HTTP REST API 交换 SDP(集成在同一进程中)
  • 网络要求:服务端与客户端在同一局域网,无需 STUN/TURN

最终效果:浏览器打开网页,自动建立连接并实时播放视频。

核心架构思路 :让 FFmpeg 负责 RTP 打包(而非手动使用 H264RtpPacketizer),C++ 通过 UDP socket 接收 RTP 包,修复 SSRC 后直接调用 track->send() 转发。这是参照 libdatachannel 官方 examples/media-sender 示例的做法,避免了手动分包与 libdatachannel 内部机制冲突导致的数据损坏。


系统架构

复制代码
┌──────────────────────────────────────┐       ┌──────────────────────────┐
│   C++ 服务端 (推流端)  Ubuntu 24.04   │       │   浏览器 (播放端)         │
│                                      │       │   局域网内任意设备         │
│  ┌────────────────────────────┐      │ HTTP  │                          │
│  │  HTTP 服务器 (cpp-httplib) │      │◄─────►│  ┌────────────────────┐  │
│  │  - 提供 index.html         │      │ 信令  │  │  index.html        │  │
│  │  - POST /api/signaling     │      │       │  │  + WebRTC API (JS) │  │
│  └──────────┬─────────────────┘      │       │  └────────┬───────────┘  │
│             │                        │       │           │              │
│  ┌──────────▼─────────────────┐      │ SRTP  │  ┌────────▼───────────┐  │
│  │  PeerConnection            │      │◄─────►│  │  PeerConnection    │  │
│  │  + Video Track (sendonly)  │      │ 视频流│  │  + <video> 元素    │  │
│  │  + SSRC 修复               │      │       │  │                    │  │
│  └──────────┬─────────────────┘      │       │  └────────────────────┘  │
│             │                        │       │                          │
│  ┌──────────▼─────────────────┐      │       │                          │
│  │  UDP Socket (recvfrom)     │      │       │                          │
│  │  接收 FFmpeg RTP 包        │      │       │                          │
│  └──────────┬─────────────────┘      │       │                          │
│             │                        │       │                          │
│  ┌──────────▼─────────────────┐      │       │                          │
│  │  FFmpeg 子进程              │      │       │                          │
│  │  ffmpeg -re -i video.mp4   │      │       │                          │
│  │  -f rtp rtp://127.0.0.1:N  │      │       │                          │
│  └────────────────────────────┘      │       │                          │
└──────────────────────────────────────┘       └──────────────────────────┘

信令流程(C++ 创建 Offer,浏览器创建 Answer):

复制代码
浏览器                          C++ 服务端
  │                                │
  │  GET / (加载页面)              │
  │◄───────────────────────────────│
  │                                │
  │  POST /api/signaling           │
  │  {type:"request"}             │
  │──────────────────────────────►│
  │                                │  创建 PeerConnection
  │                                │  添加 Video Track (H.264, sendonly)
  │                                │  生成 Offer SDP
  │  {type:"offer", sdp:"..."}    │
  │◄───────────────────────────────│
  │                                │
  │  设置 RemoteDescription (offer)│
  │  创建 Answer                   │
  │                                │
  │  POST /api/signaling           │
  │  {type:"answer", sdp:"..."}   │
  │──────────────────────────────►│
  │                                │  设置 RemoteDescription (answer)
  │                                │  启动 FFmpeg → UDP → track->send()
  │◄══════════ SRTP 视频流 ═══════│
  │                                │
  │  <video> 实时播放              │

第1步:环境准备

bash 复制代码
# 更新系统
sudo apt update && sudo apt upgrade -y

# 安装基础编译工具
sudo apt install -y build-essential cmake pkg-config git wget

第2步:安装依赖库

bash 复制代码
# FFmpeg(运行时版本,用于推流时调用 ffmpeg 命令行)
sudo apt install -y ffmpeg

# OpenSSL(libdatachannel 依赖)
sudo apt install -y libssl-dev

# libsrtp2(libdatachannel 依赖,SRTP 加密)
sudo apt install -y libsrtp2-dev

# nlohmann-json(JSON 解析,header-only)
sudo apt install -y nlohmann-json3-dev

验证依赖:

bash 复制代码
cmake --version           # 应 >= 3.15
ffmpeg -version           # 应 >= 4.4
dpkg -l | grep libssl-dev
dpkg -l | grep libsrtp2-dev

第3步:编译安装 libdatachannel

libdatachannel 是目前最成熟的 C++ WebRTC 开源实现,我们需要从源码编译。

bash 复制代码
cd ~
git clone --depth 1 --branch v0.21.2 https://github.com/paullouisageneau/libdatachannel.git
cd libdatachannel
git submodule update --init --recursive

mkdir build && cd build
cmake .. \
    -DCMAKE_BUILD_TYPE=Release \
    -DNO_MEDIA=OFF \
    -DNO_EXAMPLES=ON
make -j$(nproc)
sudo make install
sudo ldconfig

验证安装:

bash 复制代码
# 检查库文件
ls /usr/local/lib/libdatachannel*.so

# 检查头文件
ls /usr/local/include/rtc/

# 检查 pkg-config(如未找到需手动创建 .pc 文件,见故障排查)
pkg-config --modversion libdatachannel

注意 :如果 git submodule update 很慢(GitHub 访问问题),可以在 cmake 时使用系统库:

bash 复制代码
cmake .. -DCMAKE_BUILD_TYPE=Release -DNO_MEDIA=OFF -DNO_EXAMPLES=ON \
 -DUSE_SYSTEM_SRTP=ON -DUSE_SYSTEM_JSON=ON

GitHub 克隆慢的替代方案:可使用 ghproxy.net 镜像加速:

bash 复制代码
git clone --depth 1 --branch v0.21.2 https://ghproxy.net/https://github.com/paullouisageneau/libdatachannel.git

第4步:准备测试视频

使用 FFmpeg 将视频转换为 WebRTC 兼容的 H.264 格式。

方案 A:使用你自己的视频文件

bash 复制代码
ffmpeg -i your_input_video.mp4 \
    -c:v libx264 \
    -profile:v baseline \
    -level 3.1 \
    -pix_fmt yuv420p \
    -an \
    -vf "scale=640:480:force_original_aspect_ratio=decrease,pad=640:480:(ow-iw)/2:(oh-ih)/2" \
    ~/test_video.mp4

方案 B:没有视频文件时,生成测试视频(彩色图形 + 时间戳)

bash 复制代码
ffmpeg -f lavfi -i testsrc=duration=60:size=640x480:rate=30 \
    -c:v libx264 -profile:v baseline -level 3.1 -pix_fmt yuv420p \
    -an \
    ~/test_video.mp4

参数说明:

参数 含义
-profile:v baseline H.264 Baseline Profile,WebRTC 最兼容
-level 3.1 限制编码级别,确保浏览器支持
-pix_fmt yuv420p 像素格式,浏览器普遍支持
-an 去掉音频(本 Demo 仅推视频)
scale=640:480 分辨率限制

第5步:创建项目

bash 复制代码
mkdir -p ~/webrtc-streamer
cd ~/webrtc-streamer

项目需要 3 个文件(都放在同一目录):

复制代码
webrtc-streamer/
├── CMakeLists.txt
├── main.cpp
└── httplib.h          # 从 GitHub 下载(header-only)

5.1 下载 cpp-httplib(HTTP 库)

bash 复制代码
cd ~/webrtc-streamer
wget https://raw.githubusercontent.com/yhirose/cpp-httplib/v0.16.3/httplib.h
# 如果 wget 被墙,使用镜像:
# wget https://ghproxy.net/https://raw.githubusercontent.com/yhirose/cpp-httplib/v0.16.3/httplib.h

5.2 创建 CMakeLists.txt

bash 复制代码
cat > CMakeLists.txt << 'CMAKE_EOF'
cmake_minimum_required(VERSION 3.15)					//CMAKE版本
project(webrtc_streamer VERSION 1.0 LANGUAGES CXX)		//工程名 版本 语言

set(CMAKE_CXX_STANDARD 17)								//C++17
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

find_package(PkgConfig REQUIRED)						//依赖
pkg_check_modules(LIBDATACHANNEL REQUIRED libdatachannel)//lidatachannel
find_package(nlohmann_json REQUIRED)
find_package(Threads REQUIRED)

add_executable(webrtc_streamer main.cpp)				//可执行文件

target_include_directories(webrtc_streamer PRIVATE		//头文件搜索
    ${LIBDATACHANNEL_INCLUDE_DIRS}
    ${CMAKE_CURRENT_SOURCE_DIR}
)

target_link_libraries(webrtc_streamer PRIVATE			//链接
    ${LIBDATACHANNEL_LIBRARIES}
    nlohmann_json::nlohmann_json
    Threads::Threads
)
CMAKE_EOF

5.3创建index.html

html 复制代码
<!DOCTYPE html>
  <html lang="zh-CN">
  <head>
  <meta charset="UTF-8">
  <title>WebRTC 推流 Demo</title>
  </head>
  <body>
  <h1>WebRTC 推流 Demo</h1>
  <button id="btn" onclick="start()">开始播放</button>
  <pre id="st" style="background:#111;color:#0f0;padding:10px;text-align:left;min-height:100px;"></pre>
  <video id="v" autoplay playsinline muted style="max-width:95%;border:3px solid
  red;background:black;min-width:400px;min-height:300px;"></video>
  <script>
  let pc = null;
  let logLines = [];
  function set(s) {
      logLines.push(new Date().toLocaleTimeString() + ' ' + s);
      if (logLines.length > 30) logLines.shift();
      document.getElementById('st').textContent = logLines.join('\n');
      console.log(s);
  }
  async function start() {
      document.getElementById('btn').disabled = true;
      set('连接中...');
      pc = new RTCPeerConnection({ iceServers: [] });

      pc.ontrack = function(e) {
          set('ontrack: kind=' + e.track.kind + ' id=' + e.track.id + ' streams=' + e.streams.length);
          var stream = e.streams[0];
          if (!stream) {
              set('ERROR: stream 为空!');
              return;
          }
          set('stream id=' + stream.id + ' active=' + stream.active + ' tracks=' + stream.getTracks().length);
          var vt = stream.getVideoTracks();
          if (vt.length === 0) {
              set('WARN: stream 没有视频轨');
          } else {
              set('视频轨: readyState=' + vt[0].readyState + ' muted=' + vt[0].muted);
              vt[0].onunmute = function() { set('视频轨 unmuted'); };
              vt[0].onended = function() { set('视频轨 ended'); };
          }
          var v = document.getElementById('v');
          v.srcObject = stream;
          set('srcObject 已设置');
          v.onloadedmetadata = function() { set('metadata loaded: ' + v.videoWidth + 'x' + v.videoHeight); };
          v.onplay = function() { set('video onplay'); };
          v.onwaiting = function() { set('video onwaiting'); };
          v.onerror = function() { set('video error: ' + (v.error ? v.error.message : 'unknown')); };
          v.play().then(function() { set('play() resolved'); }).catch(function(err) { set('play() rejected: ' +
  err.message); });
      };

      pc.oniceconnectionstatechange = function() {
          set('ICE:' + pc.iceConnectionState);
      };
      pc.onconnectionstatechange = function() {
          set('Conn:' + pc.connectionState);
      };

      try {
          set('获取Offer...');
          var r1 = await fetch('/api/offer');
          var offer = await r1.json();
          set('Offer收到, len=' + offer.sdp.length);

          // 解析 Offer SDP 中的 video 行
          var offerLines = offer.sdp.split('\n');
          for (var i = 0; i < offerLines.length; i++) {
              if (offerLines[i].indexOf('m=video') === 0 || offerLines[i].indexOf('a=rtpmap') === 0 ||
  offerLines[i].indexOf('a=fmtp') === 0) {
                  set('Offer: ' + offerLines[i]);
              }
          }

          await pc.setRemoteDescription(new RTCSessionDescription(offer));
          set('远端Offer已设置');

          var answer = await pc.createAnswer();
          await pc.setLocalDescription(answer);

          var answerLines = answer.sdp.split('\n');
          for (var i = 0; i < answerLines.length; i++) {
              if (answerLines[i].indexOf('m=video') === 0 || answerLines[i].indexOf('a=rtpmap') === 0 ||
  answerLines[i].indexOf('a=fmtp') === 0) {
                  set('Answer: ' + answerLines[i]);
              }
          }

          await new Promise(function(resolve) {
              if (pc.iceGatheringState === 'complete') { resolve(); return; }
              var h = function() {
                  if (pc.iceGatheringState === 'complete') {
                      pc.removeEventListener('icegatheringstatechange', h);
                      resolve();
                  }
              };
              pc.addEventListener('icegatheringstatechange', h);
              setTimeout(function() {
                  pc.removeEventListener('icegatheringstatechange', h);
                  resolve();
              }, 5000);
          });

          set('发送Answer...');
          var r2 = await fetch('/api/answer', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ type: 'answer', sdp: pc.localDescription.sdp })
          });
          set('Answer已发送, status=' + r2.status);
          set('等待连接...');
      } catch(err) {
          set('ERROR: ' + err.message);
          console.error(err);
          document.getElementById('btn').disabled = false;
      }
  }
  </script>
  </body>
  </html>

5.4 创建 main.cpp

c++ 复制代码
#include <sys/wait.h>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <csignal>
#include <cstring>
#include <fstream>
#include <iostream>
#include <memory>
#include <mutex>
#include <sstream>
#include <thread>

#include <rtc/rtc.hpp>
#include "httplib.h"
#include <nlohmann/json.hpp>

#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <signal.h>

  using json = nlohmann::json;

  std::atomic<bool> g_running{true};
  std::unique_ptr<httplib::Server> g_httpServer;

  std::shared_ptr<rtc::PeerConnection> g_pc;
  std::shared_ptr<rtc::Track>          g_videoTrack;
  const rtc::SSRC g_ssrc = 42;

  std::thread g_streamThread;
  pid_t       g_ffmpegPid  = 0;

  std::mutex              g_signalingMutex;
  std::condition_variable g_signalingCv;
  std::string             g_offerSdp;
  bool                    g_offerReady = false;

  static std::string g_html;
  static std::string g_videoPath;

  static bool loadHtmlFile() {
      std::ifstream f("index.html");
      if (!f.is_open()) { std::cerr << "[!] index.html missing" << std::endl; return false; }
      std::stringstream ss; ss << f.rdbuf(); g_html = ss.str();
      return true;
  }

  static void streamingThread(int udpPort) {
      std::cout << "[stream] waiting..." << std::endl;
      {
          std::unique_lock<std::mutex> lk(g_signalingMutex);
          g_signalingCv.wait(lk, [] { return g_offerReady || !g_running; });
      }
      if (!g_running) return;

      // 启动 FFmpeg(后台,RTP 输出到 UDP)
      std::string cmd = "ffmpeg -re -stream_loop -1 -i \"" + g_videoPath +
          "\" -an -c:v libx264 -profile:v baseline -level 3.1 -tune zerolatency -f rtp rtp://127.0.0.1:" +
          std::to_string(udpPort) + " > /tmp/ffmpeg.log 2>&1 & echo $!";
      std::cout << "[stream] cmd: " << cmd << std::endl;
      FILE* fp = popen(cmd.c_str(), "r");
      if (fp) {
          char buf[32];
          if (fgets(buf, sizeof(buf), fp)) g_ffmpegPid = (pid_t)std::stoi(buf);
          pclose(fp);
          std::cout << "[stream] ffmpeg pid=" << g_ffmpegPid << std::endl;
      }

      std::this_thread::sleep_for(std::chrono::milliseconds(500));

      // 创建 UDP socket
      int sock = socket(AF_INET, SOCK_DGRAM, 0);
      if (sock < 0) { std::cerr << "[!] socket" << std::endl; return; }

      struct sockaddr_in addr = {};
      addr.sin_family = AF_INET;
      addr.sin_addr.s_addr = inet_addr("127.0.0.1");
      addr.sin_port = htons(udpPort);

      if (bind(sock, (const sockaddr*)&addr, sizeof(addr)) < 0) {
          std::cerr << "[!] bind port " << udpPort << std::endl;
          close(sock); return;
      }
      int rcvBuf = 212992;
      setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &rcvBuf, sizeof(rcvBuf));

      std::cout << "[stream] listening UDP :" << udpPort << std::endl;

      std::vector<uint8_t> buf(4096);
      int n = 0;
      while (g_running) {
          ssize_t len = recv(sock, buf.data(), buf.size(), 0);
          if (len < 0) {
              if (errno == EINTR) continue;
              break;
          }
          if (len < (ssize_t)sizeof(rtc::RtpHeader)) continue;
          if (!g_videoTrack || !g_videoTrack->isOpen()) continue;

          auto rtp = reinterpret_cast<rtc::RtpHeader*>(buf.data());
          rtp->setSsrc(g_ssrc);

          g_videoTrack->send(
              reinterpret_cast<const std::byte*>(buf.data()), (size_t)len);
          n++;
          if (n % 900 == 0)
              std::cout << "[stream] " << n << " pkts" << std::endl;
      }
      close(sock);
      std::cout << "[stream] done " << n << " pkts" << std::endl;
  }

  static std::string getLocalSdp() {
      auto o = g_pc->localDescription();
      return o.has_value() ? std::string(o.value()) : "";
  }

  static bool createOffer() {
      try {
          g_pc = std::make_shared<rtc::PeerConnection>(rtc::Configuration{});
          g_pc->onStateChange([](auto s) {
              std::cout << "[RTC] state=" << (int)s << std::endl; });
          g_pc->onGatheringStateChange([](auto s) {
              std::cout << "[RTC] ice=" << (int)s << std::endl; });

          // 完全参照官方 media-sender 示例
          rtc::Description::Video media("video",
              rtc::Description::Direction::SendOnly);
          media.addH264Codec(96);
          media.addSSRC(g_ssrc, "video-send");
          g_videoTrack = g_pc->addTrack(media);

          // 不创建 H264RtpPacketizer!不设 setMediaHandler!
          // FFmpeg 负责 RTP 打包,这里只转发

          g_pc->setLocalDescription();

          std::mutex m; std::condition_variable c; bool done = false;
          g_pc->onGatheringStateChange([&](auto s) {
              if (s == rtc::PeerConnection::GatheringState::Complete) {
                  std::lock_guard<std::mutex> lk(m);
                  g_offerSdp = getLocalSdp(); done = true; c.notify_one();
              }
          });
          {
              std::unique_lock<std::mutex> lk(m);
              if (!c.wait_for(lk, std::chrono::seconds(15), [&] { return done; }))
                  g_offerSdp = getLocalSdp();
          }
          std::cout << "[RTC] Offer ready len=" << g_offerSdp.size() << std::endl;
          return true;
      } catch (const std::exception& e) {
          std::cerr << "[RTC] " << e.what() << std::endl; return false;
      }
  }

  static void startHttpServer(int port) {
      g_httpServer = std::make_unique<httplib::Server>();

      g_httpServer->Get("/", [](auto&, auto& res) {
          res.set_content(g_html, "text/html; charset=utf-8");
      });

      g_httpServer->Get("/api/offer", [](auto&, auto& res) {
          if (g_pc) { g_pc->close(); g_pc.reset(); }
          if (!createOffer()) {
              res.status = 500;
              res.set_content(R"({"error":"offer"})", "application/json");
              return;
          }
          res.set_content(
              json{{"type","offer"},{"sdp",g_offerSdp}}.dump(),
              "application/json");
      });

      g_httpServer->Post("/api/answer", [](auto& req, auto& res) {
          try {
              json j = json::parse(req.body);
              std::string sdp = j.value("sdp", "");
              if (sdp.empty()) { res.status = 400; return; }
              g_pc->setRemoteDescription(rtc::Description(sdp, "answer"));
              g_pc->onStateChange([](auto s) {
                  if (s == rtc::PeerConnection::State::Connected) {
                      std::lock_guard<std::mutex> lk(g_signalingMutex);
                      if (!g_offerReady) {
                          g_offerReady = true;
                          g_signalingCv.notify_all();
                          std::cout << "[RTC] connected!" << std::endl;
                      }
                  }
              });
              res.set_content(R"({"status":"ok"})", "application/json");
          } catch (const std::exception& e) {
              res.status = 500;
              res.set_content(json{{"error",e.what()}}.dump(), "application/json");
          }
      });

      std::cout << "\n>>> http://0.0.0.0:" << port << " <<<\n" << std::endl;
      g_httpServer->listen("0.0.0.0", port);
  }

  static void cleanup() {
      g_running = false;
      g_signalingCv.notify_all();

      if (g_ffmpegPid > 0) {
          kill(g_ffmpegPid, SIGTERM);
          int waitCount = 0;
          while (waitpid(g_ffmpegPid, nullptr, WNOHANG) == 0 && waitCount < 30) {
              std::this_thread::sleep_for(std::chrono::milliseconds(100));
              ++waitCount;
          }
          if (waitpid(g_ffmpegPid, nullptr, WNOHANG) == 0) {
              kill(g_ffmpegPid, SIGKILL);
              waitpid(g_ffmpegPid, nullptr, 0);
          }
      }

      if (g_streamThread.joinable()) g_streamThread.join();
      if (g_pc) g_pc->close();

      std::cout << "Program exited" << std::endl;

  }

  static void sigHandler(int) { cleanup(); }

  int main(int argc, char* argv[]) {
      g_videoPath = "test_video.mp4";
      int port = 8080;
      int udpPort = 6000;
      if (argc >= 2) g_videoPath = argv[1];
      if (argc >= 3) port = std::stoi(argv[2]);
      if (argc >= 4) udpPort = std::stoi(argv[3]);

      std::cout << "=== WebRTC Streamer ===\n"
                << "video: " << g_videoPath << "\n"
                << "http : " << port << "\n"
                << "udp  : " << udpPort << std::endl;

      signal(SIGINT, sigHandler);
      signal(SIGTERM, sigHandler);

      if (!loadHtmlFile()) return 1;

      rtc::InitLogger(rtc::LogLevel::Warning);

      g_streamThread = std::thread(streamingThread, udpPort);
      startHttpServer(port);

      cleanup();
      if (g_streamThread.joinable()) g_streamThread.join();
      if (g_pc) g_pc->close();
      return 0;
  }

第6步:编译项目

bash 复制代码
cd ~/webrtc-streamer
mkdir -p build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
make -j$(nproc)

编译成功后,build 目录下会生成 webrtc_streamer 可执行文件。

常见编译错误

错误1fatal error: nlohmann/json.hpp: No such file or directory

bash 复制代码
# 手动下载 header-only 版本到项目目录
cd ~/webrtc-streamer
wget https://github.com/nlohmann/json/releases/download/v3.11.3/json.hpp

# 然后修改 main.cpp 中的 #include:
# 将 #include <nlohmann/json.hpp> 改为 #include "json.hpp"

同时也修改 CMakeLists.txt,去掉 find_package(nlohmann_json REQUIRED)nlohmann_json::nlohmann_json 这两行。

错误2undefined reference to rtc::*

说明 libdatachannel 没有正确安装。重新执行第3步,确认 sudo ldconfig 已执行。

错误3fatal error: rtc/rtc.hpp: No such file or directory

bash 复制代码
# 确认头文件位置
ls /usr/local/include/rtc/
# 如果不存在,重新执行 sudo make install

错误4pkg-config: No package 'libdatachannel' found

手动创建 pkg-config 文件:

bash 复制代码
sudo tee /usr/local/lib/pkgconfig/libdatachannel.pc > /dev/null << 'EOF'
prefix=/usr/local
exec_prefix=${prefix}
libdir=${exec_prefix}/lib
includedir=${prefix}/include

Name: libdatachannel
Description: WebRTC Data Channels and Media Transport
Version: 0.21.2
Libs: -L${libdir} -ldatachannel
Cflags: -I${includedir}
EOF

第7步:运行测试

7.1 启动服务端

bash 复制代码
cd ~/webrtc-streamer/build

# 将测试视频复制到 build 目录
cp ~/test_video.mp4 .

# 启动(默认使用 test_video.mp4 + HTTP 端口 8080, UDP 端口 9080)
./webrtc_streamer

# 或指定视频文件和端口
./webrtc_streamer /home/yourname/myvideo.mp4 9090

启动后输出类似:

复制代码
=== WebRTC Video Streaming Demo ===
Video file: test_video.mp4
HTTP port:  8080
UDP port:   9080

========================================
HTTP server started: http://0.0.0.0:8080
========================================

[stream] Waiting for browser answer...
[WebRTC] Generated SSRC: 268435456 (0x10000000)
...

7.2 浏览器访问

  1. 在 Ubuntu 本机测试 :打开浏览器访问 http://localhost:8080

    • 页面会自动发起连接,无需点击按钮
    • 几秒后即可看到视频
  2. 从局域网其他设备访问:先查看 Ubuntu 的 IP:

    bash 复制代码
    hostname -I
    # 或
    ip addr show | grep "inet " | grep -v 127.0.0.1

    然后在其他设备的浏览器输入 http://<IP>:8080,例如 http://192.168.1.100:8080

7.3 完整运行日志示例

复制代码
=== WebRTC Video Streaming Demo ===
Video file: test_video.mp4
HTTP port:  8080
UDP port:   9080

========================================
HTTP server started: http://0.0.0.0:8080
========================================

[stream] Waiting for browser answer...
[signaling] Browser requested offer
[WebRTC] Generated SSRC: 270123456 (0x101a2c40)
[WebRTC] Offer created, SDP length: 952 bytes
[signaling] Offer sent to browser
[signaling] Received answer, SDP length: 784 bytes
[signaling] RemoteDescription set (answer)
[WebRTC] ICE gathering state: 2
[signaling] Answer processed, streaming will start
[stream] Starting FFmpeg: ffmpeg -re -stream_loop -1 -i test_video.mp4 ...
[stream] UDP socket bound to port 9080, waiting for RTP packets...
[WebRTC] Connection state: 3
[stream] Forwarded 300 RTP packets
[stream] Forwarded 600 RTP packets

第8步:局域网其他设备访问配置

8.1 检查防火墙

Ubuntu 24.04 默认防火墙通常未启用,但如果有:

bash 复制代码
# 检查防火墙状态
sudo ufw status

# 如果启用了,开放 HTTP 端口
sudo ufw allow 8080/tcp

# WebRTC 媒体数据走 UDP 随机端口,测试时可临时关闭防火墙
sudo ufw disable   # 仅测试用

8.2 确认网络连通性

从客户端设备 ping Ubuntu 服务器:

bash 复制代码
ping 192.168.1.100   # 替换为实际 IP

8.3 从手机/平板测试

在同一 WiFi 下,手机浏览器访问 http://<Ubuntu_IP>:8080,页面自动连接并播放。


故障排查

Q1:浏览器一直显示"ICE: checking",连不上

原因:WebRTC 的 ICE 连接无法建立。

排查

  • 确保浏览器和服务器在同一局域网同一网段(如都是 192.168.1.x)

  • 检查是否有防火墙阻止 UDP 端口:

    bash 复制代码
    sudo iptables -L -n | grep -i drop
  • 尝试在 Ubuntu 上临时关闭防火墙:

    bash 复制代码
    sudo ufw disable

Q2:页面加载后无响应,日志停在"Requesting offer..."

原因:信令 API 未响应。

排查

bash 复制代码
# 检查服务端是否在运行
ss -tlnp | grep 8080

# 手动测试信令 API
curl -X POST http://localhost:8080/api/signaling \
  -H "Content-Type: application/json" \
  -d '{"type":"request"}'

Q3:收到视频流但黑屏/白屏

原因:通常是 FFmpeg RTP 推流有问题,或者 H.264 编码格式不兼容。

排查步骤

  1. 首先检查 FFmpeg 日志(这是最重要的第一步):

    bash 复制代码
    cat /tmp/ffmpeg.log
    • 如果文件为空 → FFmpeg 没有启动或静默失败了
    • 如果有 Codec not foundInvalid data → 源视频编码不兼容(见下方解决方案)
    • 如果有 speed= 等信息 → FFmpeg 正常运行,问题在 RTP 转发链路
  2. 确认源视频编码

    bash 复制代码
    ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,profile -of csv=p=0 test_video.mp4

    输出应为 h264,Constrained Baselineh264,Baseline

  3. 重新编码视频(如果编码不对):

    bash 复制代码
    ffmpeg -i your_video.mp4 -c:v libx264 -profile:v baseline -level 3.1 \
        -pix_fmt yuv420p -an -vf "scale=640:480:force_original_aspect_ratio=decrease,pad=640:480:(ow-iw)/2:(oh-ih)/2" \
        ~/test_video.mp4
  4. 如果日志正常但仍黑屏 ,检查 UDP socket 是否收到数据:

    在 main.cpp 的 recvfrom 后加一行调试日志(临时):

    cpp 复制代码
    std::cout << "RTP packet: " << len << " bytes, seq=" << ntohs(*(uint16_t*)(buffer+2)) << std::endl;

    确认有 RTP 包在接收。

Q4:编译时提示 rtc:😗 API 不匹配

原因:libdatachannel 版本不同,API 可能有变化。

解决

  • 确认安装的是 v0.21.2 版本

  • 查看实际安装的头文件:

    bash 复制代码
    grep -r "addH264Codec\|addSSRC\|setMediaHandler" /usr/local/include/rtc/
  • 如果使用更新版本(v0.22+),localDescription() 返回 std::optional<rtc::Description>,需要用 .has_value().value() 处理

Q5:想让视频循环播放

main.cpp 中 FFmpeg 已经使用 -stream_loop -1 参数,视频会自动循环播放。

Q6:想支持多个客户端同时观看

当前代码只支持单客户端。扩展方法:

  • 为每个信令请求创建独立的 PeerConnection 和推流线程
  • 或使用一个推流线程向多个 PeerConnection 的 track 发送数据
  • std::vector<std::shared_ptr<rtc::PeerConnection>> 管理多个连接

Q7:FFmpeg 子进程没有正常退出

程序退出时会自动向 FFmpeg 子进程发送 SIGTERM。如果残留了僵尸进程:

bash 复制代码
pkill -f "ffmpeg.*rtp://127.0.0.1"

进阶扩展

添加音频支持

createOffer() 中添加音频 Track:

cpp 复制代码
auto audioDesc = rtc::Description::Audio("audio",
                     rtc::Description::Direction::SendOnly);
audioDesc.addOpusCodec(111);
auto audioTrack = g_pc->addTrack(audioDesc);

FFmpeg 命令添加音频流:

bash 复制代码
ffmpeg -re -stream_loop -1 -i video.mp4 \
    -c:v copy -c:a libopus -ar 48000 -ac 2 \
    -f rtp rtp://127.0.0.1:VIDEO_PORT \
    -f rtp rtp://127.0.0.1:AUDIO_PORT

然后在推流线程中创建两个 UDP socket 分别接收视频和音频 RTP 包。

添加 STUN 服务器(跨网段)

rtc::Configuration 中添加:

cpp 复制代码
config.iceServers.emplace_back("stun:stun.l.google.com:19302");

Docker 部署

创建 Dockerfile

dockerfile 复制代码
FROM ubuntu:24.04
RUN apt update && apt install -y build-essential cmake ffmpeg ...
# ... 编译和安装步骤
EXPOSE 8080
CMD ["./webrtc_streamer", "/data/video.mp4", "8080"]

总结

走过的弯路

最初的方案(也是网上许多教程的做法)是本指南的旧版本

  • H264RtpPacketizer 手动将 H.264 NAL 单元打包成 RTP
  • 调用 track->setMediaHandler() 设置媒体处理链
  • 浏览器创建 Offer,服务端创建 Answer

这个方案在 libdatachannel v0.21.2 上完全不能工作 ,表现为浏览器黑屏或有 ontrack 事件但无画面。根因是 libdatachannel 内部的 setMediaHandler 机制与手动调 outgoingChain 存在冲突,导致 RTP 包被二次处理而损坏。

正确的方案

参照 libdatachannel 官方 examples/media-sender 的写法:

职责 由谁完成 方式
H.264 编码 FFmpeg -c:v libx264-c:v copy(源已是 baseline)
RTP 打包 FFmpeg -f rtp rtp://127.0.0.1:PORT
RTP 传输 UDP socket recvfrom() 接收
SSRC 修复 C++ memcpy(buffer+8, &ourSSRC, 4)
WebRTC 发送 C++ track->send(buffer, len) 直接转发
信令 C++ 创建 Offer 避免 m-line 不匹配

一句话:不要让 libdatachannel 碰 RTP 打包,FFmpeg 已经做好了。C++ 只做三件事------收 UDP 包、改 SSRC、转发。

关键调试技巧

  1. cat /tmp/ffmpeg.log --- 第一个要看的,能回答"FFmpeg 到底在不在推流"
  2. pkill -f ffmpeg; pkill -f webrtc --- 出问题时先清理僵尸进程
  3. 浏览器 F12 Console --- 看 ontrack 是否触发、ICE 状态变化
  4. 检查视频编码 --- ffprobe 确认是 H.264 baseline,否则 -c:v copy 会静默失败
  5. 如果换视频文件后黑屏 --- 十有八九是编码不兼容,重新用第4步转码

参考资源

相关推荐
meilindehuzi_a1 小时前
深入理解 Linux 进程地址空间:从虚拟内存、页表映射到 fork 写时拷贝与 VMA 管理
java·linux·服务器
z20348315201 小时前
Ubuntu双系统安装指南:从 UEFI 原理到 Ubuntu 分区方案的全面解析
linux·运维·ubuntu
wangjing_05222 小时前
通过CentOS 7虚拟机的Docker服务部署Node-RED及气象数据显示界面搭建完整教程
linux·docker·centos
万叶学编程2 小时前
服务器网站运维指令全解| Linux 实操完整教程
linux·运维·服务器
风曦Kisaki2 小时前
# Kubernetes(K8s)笔记Day13 :工作负载资源概述与 Job (CronJob)控制器
linux·笔记·云原生·容器·kubernetes
vortex52 小时前
ss 命令指南:网络排查神器
linux·运维·网络·终端
Shadow(⊙o⊙)3 小时前
Linux网络部分——基于UDP的四大接口,实例应用,常考汇总,完整服务端、客户端设计流程+代码
linux·网络·udp
深念Y3 小时前
老Flyme 官方 root 开启 ADB TCP 方案
linux·数据库·网络协议·tcp/ip·adb·智能手机·emmc
小此方3 小时前
Re:Linux系统篇(五十二)线程篇 · 五:直击 Linux 内核底层:TCB 与 LWP 一体两面全解析(附自定义线程封装)
linux·运维·驱动开发