环境配置
C++ 项目的依赖管理相比 Java 和 Python 要复杂得多。由于缺乏统一的跨平台包管理生态(虽然有 vcpkg、conan 等工具,但覆盖率有限),很多第三方库——尤其是音视频相关——仍需要手动下载源码并编译,这会带来版本兼容和构建环境配置上的额外成本。同时,C++ 项目的跨平台适配工作量也相对较大,不像 Java 有 JVM 屏蔽平台差异,Python 是解释执行。在没有使用交叉编译工具链的情况下,Linux 下直接编译的二进制文件是无法在 Windows 上运行的。
linux下的播放器配置
项目初期想在wsl做,所以选择的环境是ubuntu wsl 2204
qt配置
- 安装Qt的组件
sudo apt-get install build-essential
- 安装Qt的开发工具
sudo apt-get install qtbase5-dev qtchooser qt5-qmake qtbase5-dev-tools
- 安装qtcreator
sudo apt-get install qtcreator
- 安装qt
sudo apt-get install qt5*
- wsl中文字体
sudo ln -s /mnt/c/Windows/Fonts /usr/share/fonts/font
fc-cache -fv
ffmpeg
在git上下载源代码
- configure
./configure \
--prefix=/home/iwan/code/FFmpeg/build64 \
--enable-shared \
--disable-static \
--disable-programs \
--disable-doc \
--disable-debug
makemake install
cmake
cmake_minimum_required(VERSION 3.16.0)
project(player VERSION 0.1.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
# Qt
find_package(Qt5 REQUIRED COMPONENTS Widgets)
# output path
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${PROJECT_SOURCE_DIR}/bin)
# FFmpeg include 和 lib 路径
set(FFMPEG_DIR /home/iwan/code/FFmpeg/build64)
# include path
include_directories(${FFMPEG_DIR}/include) # 🔧 头文件路径
include_directories(${PROJECT_SOURCE_DIR}/include)
# lib path
link_directories(${FFMPEG_DIR}/lib) # 🔧 库文件路径
set(CMAKE_INSTALL_RPATH "${FFMPEG_DIR}/lib")
add_executable(player
# src include
)
target_link_libraries(player
Qt5::Widgets
avfilter
avformat
avcodec
avutil
swresample
swscale
)
迁移到windows mysy2
由于需要摄像头进行视频采集,wsl要调用到摄像头好麻烦所以就移植到windows了
安装mysys2
下载mysys2安装包,安装一些需要的基础环境
基础工具
pacman -S --needed base-devel git cmake ninja pkg-config
安装FFmpeg,sdl,openssl,opencv,qt
pacman -S mingw-w64-x86_64-ffmpeg
pacman -S mingw-w64-x86_64-SDL2
pacman -S mingw-w64-x86_64-openssl
pacman -S mingw-w64-x86_64-opencv
pacman -S mingw-w64-x86_64-qt5
编译安装 libdatachannel
git clone --recursive https://github.com/paullouisageneau/libdatachannel.git
安装相关依赖
sudo apt update
sudo apt install -y \
libnice-dev libsrtp2-dev libssl-dev \
cmake build-essential pkg-config
build config
cmake -B build -DUSE_GNUTLS=0 -DUSE_NICE=1 -DCMAKE_BUILD_TYPE=Release
cmake --build build
sudo cmake --install build
build
cmake --build build
最后在程序编译后生成可执行程序,执行时会需要一些dll动态链接库,要复制到统一个目录下,qt可能会存在版本冲突,所以在系统环境变量上要调整下顺序。
播放器
主要流程
- demuxer,解复用,得到视频format的上下文信息,并且得到视频流和音频流的idx
int Demuxer::open_file(const QString &file_path) {
//防止重复打开
close();
int ret = 0;
// 打开视频
ret = avformat_open_input(&fmt_ctx, file_path.toStdString().c_str(), nullptr,
nullptr);
if (ret < 0) {
qDebug() << "avformat_open_input failed";
return -1;
}
// 获取流信息
ret = avformat_find_stream_info(fmt_ctx, nullptr);
if (ret < 0) {
qDebug() << "av_find_stream_info failed";
return -1;
}
// 查找视频流
video_idx =
av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0);
if (video_idx < 0) {
qDebug() << "No video stream found";
}
// 查找音频流
audio_idx =
av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0);
if (audio_idx < 0) {
qDebug() << "No audio stream found";
}
qDebug() << "Demux success, video idx:" << video_idx
<< "audio idx:" << audio_idx;
return 0;
}
- 打开音视频流,这里做了一个继承的操作,所以处理的接口是一样的
视频流
int Controller::open_video_stream(AVFormatContext *fmt_ctx) {
int v_idx = demuxer.get_video_idx();
if (v_idx < 0) {
qDebug() << "controller::open v_idx failed";
return -1;
}
int ret = video_decoder.init_decoder(fmt_ctx, v_idx);
if (ret < 0) {
qDebug() << "no video steam";
return 0;
}
AVCodecContext *v_ctx = video_decoder.get_codec_ctx();
ret = video_processor.init_video_processor(v_ctx);
if (ret < 0) {
qDebug() << "controller::open_video_stream video_processor init failed";
return -1;
}
return 0;
}
音频流
int Controller::open_audio_stream(AVFormatContext *fmt_ctx) {
int a_idx = demuxer.get_audio_idx();
if (a_idx < 0) {
qDebug() << "no audio stream";
return 0;
}
int ret = audio_decoder.init_decoder(fmt_ctx, a_idx);
if (ret < 0) {
qDebug() << "controller::open_audio_stream open_stream failed";
return -1;
}
AVCodecContext *a_ctx = audio_decoder.get_codec_ctx();
ret = audio_processor.init_audio_processor(a_ctx);
if (ret < 0) {
qDebug() << "controller::open_audio_stream audio_processor init failed";
return -1;
}
return 0;
}
- 初始化解码器decoder,根据流的idx获取相应的编解码参数,并且根据参数中codec的id找到对应的codec编解码器,再根据这个codec分配一个codec的ctx上下文存储解码运行所需的所有信息,最后用用 codec 打开 codec_ctx,完成解码器实例的初始化。此后,这个 codec_ctx 就可以被用来接收压缩包(AVPacket)并输出解码后的帧(AVFrame)。
int Decoder::init_decoder(AVFormatContext *fmt_ctx, int stream_idx) {
int ret = 0;
if (stream_idx < 0) {
qDebug() << "stream_idx not found";
return -1;
}
stream = fmt_ctx->streams[stream_idx];
AVCodecParameters *codec_par = fmt_ctx->streams[stream_idx]->codecpar;
codec = avcodec_find_decoder(codec_par->codec_id);
if (!codec) {
qDebug() << "avcodec_find_codec failed";
return -1;
}
codec_ctx = avcodec_alloc_context3(codec);
if (!codec_ctx) {
qDebug() << "avcodec_alloc_context3 failed";
return -1;
}
ret = avcodec_parameters_to_context(codec_ctx, codec_par);
if (ret < 0) {
qDebug() << "avcodec_parameters_to_context failed";
return -1;
}
ret = avcodec_open2(codec_ctx, codec, nullptr);
if (ret < 0) {
qDebug() << "avcodec_open2 failed";
return -1;
}
this->stream_idx = stream_idx;
qDebug() << "video stream open success, stream index:" << stream_idx;
return 0;
}
- init_xxx_processor,这个部分是初始化一些音视频解析输出时要用的信息,视频的比较简单就是高度和宽度。音频的就比较多了有采样率 (sample_rate)、声道数 (channels)、采样格式 (sample_fmt),还要初始化SDL音频输出,打开默认输出设备,配置重采样器
int VideoProcessor::init_video_processor(AVCodecContext *codec_ctx) {
if (!codec_ctx) return -1;
width = codec_ctx->width;
height = codec_ctx->height;
return 0;
}
int AudioProcessor::init_audio_processor(AVCodecContext *codec_ctx) {
if (is_init) {
qDebug() << "Audio already initialized.";
return 0;
}
int sample_rate = codec_ctx->sample_rate;
int channels = codec_ctx->ch_layout.nb_channels;
AVSampleFormat fmt = codec_ctx->sample_fmt;
SDL_AudioSpec want_spec;
SDL_zero(want_spec);
want_spec.freq = sample_rate;
want_spec.format = AUDIO_S16SYS;
want_spec.channels = channels;
want_spec.samples = 1024;
want_spec.callback = nullptr;
dev_id = SDL_OpenAudioDevice(nullptr, 0, &want_spec, &audio_spec, 0);
if (dev_id == 0) {
qDebug() << "Failed to open audio device" << SDL_GetError();
return -1;
}
// 设置输入和输出声道布局
av_channel_layout_default(&in_layout, channels);
av_channel_layout_default(&out_layout, channels);
int ret = 0;
ret = swr_alloc_set_opts2(&swr_ctx, &out_layout, target_fmt, sample_rate,
&in_layout, fmt, sample_rate, 0, nullptr);
if (ret < 0) {
qDebug() << "Failed to init SwrContext.";
return -1;
}
ret = swr_init(swr_ctx);
if (ret < 0) {
qDebug() << "Failed to init Swr.";
return -1;
}
SDL_PauseAudioDevice(dev_id, 0);
is_init = true;
return 0;
}
- 根据qt的timer,定时触发
update_frame,这里有个音视频的同步操作。
audio_clock 表示当前音频播放到的时间(秒),是播放器同步的基准时间。 计算方式是
- 计算每秒字节数 bytes_per_sec = 采样率 * 声道数 * 每个采样的字节数。例如:48kHz * 2 声道 * 16 位 = 192000 字节/秒
- 获取当前缓冲区字节数 queue_bytes = SDL_GetQueuedAudioSize(dev_id); 这是 SDL 播放设备中等待播放的音频数据大小(单位:字节)。
- 转换成延迟时间 delay = queue_bytes / bytes_per_sec;
- 用 audio_pts 减去延迟 return audio_pts - delay; audio_pts 表示解码器送入缓冲的最后一帧音频的时间戳减去缓冲延迟,得到此刻声卡正在播放的位置
frame->pts * av_q2d(stream->time_base);pts通过这个计算得到,ac_q2d是分数转为浮点数,将时间戳的形式转为秒的形式,
video 就直接使用video_pts 表示当前要显示的视频帧的显示时间(秒)
我们将音频的播放作为一个基准主时钟,因为视频播放可以通过跳帧或延迟显示来跟上音频会比较方便,少一点帧数的影响也比较小。
- audio_clock:当前音频播放到的时间(已经减去缓冲延迟) → 基准
- video_pts:当前视频帧应该显示的时间(来自解码器 PTS) → 目标
理想情况下,video_pts 应该恰好等于 audio_clock。但实际中,解码、渲染、线程调度都有延迟,如果要求完全相等会造成视频帧等待过久、卡顿。所以给了一个 50ms 容忍窗口,如果视频的时间戳比音频时钟落后不超过 50ms,就立即显示。如果落后多了就丢弃跳帧,如果提前了那就等待
double AudioProcessor::get_audio_clock() const {
if (!is_init) return 0.0;
int bytes_per_sec = audio_spec.freq * audio_spec.channels *
SDL_AUDIO_BITSIZE(audio_spec.format);
Uint32 queue_bytes = SDL_GetQueuedAudioSize(dev_id);
double delay = static_cast<double>(queue_bytes) / bytes_per_sec;
return audio_pts - delay;
// if (frame->pts != AV_NOPTS_VALUE) {
// audio_pts = frame->pts * av_q2d(stream->time_base);
// }
}
double VideoProcessor::get_video_pts() const {
return video_pts;
// if (frame->pts != AV_NOPTS_VALUE) {
// video_pts = frame->pts * av_q2d(stream->time_base);
// }
}
void MainWindow::update_frame() {
if (controller.decode_frame() < 0) {
timer->stop();
return;
}
double audio_clock = controller.get_audio_clock();
double video_pts = controller.get_video_pts();
if (video_pts <= audio_clock + 0.05) {
QImage img = controller.get_cur_image();
if (!img.isNull()) {
ui->labelVideo->setPixmap(QPixmap::fromImage(img).scaled(
ui->labelVideo->size(), Qt::KeepAspectRatio,
Qt::SmoothTransformation));
}
}
double pos = audio_clock;
double duration = controller.get_duration();
if (duration > 0) {
int value = static_cast<int>((pos / duration) * 100);
ui->sliderProgress->blockSignals(true);
ui->sliderProgress->setValue(value);
ui->sliderProgress->blockSignals(false);
ui->lableTime->setText(
QString("%1 / %2").arg(format_time(pos)).arg(format_time(duration)));
}
}
- decode_frame,再update_frame中被调用,先从demuxer中先读取一个frame(这里的一帧不是解码后的原始帧,而是压缩码流的一部分:对视频来说,可能是一个完整的 H.264 NAL 单元,也可能是 B 帧、P 帧的数据。对音频来说,可能是一帧 AAC、MP3、Opus 数据),通过send_packet将压缩的pkt数据送进解码器,并且使用receive_packet得到解码后的数据。
video的process_frame需要做的是检查宽高,准备sws_format格式变化的上下文与缓冲区,调用sws_scale将原本的图像格式转换到目标格式,计算并更新 video_pts audio的process_frame需要取输入通道数与样本数,用av_samples_get_buffer_size 为输出分配一块临时缓冲,swr_convert 做重采样与重排格式,用 frame->pts 计算 audio_pts,把转换后的数据通过 SDL_QueueAudio 入队播放。
int Controller::decode_frame() {
int ret = 0;
ret = av_read_frame(demuxer.get_fmt_ctx(), pkt);
if (ret < 0) {
qDebug() << "controller::play_one_frame read_frame failed";
return -1;
}
if (pkt->stream_index == video_decoder.get_stream_idx()) {
if (video_decoder.send_packet(pkt) >= 0) {
while (video_decoder.reveive_frame(frame) == 0) {
video_processor.process_frame(frame, video_decoder.get_codec_ctx(),
video_decoder.get_stream());
}
}
} else if (pkt->stream_index == audio_decoder.get_stream_idx()) {
if (audio_decoder.send_packet(pkt) >= 0) {
while (audio_decoder.reveive_frame(frame) == 0) {
audio_processor.process_frame(frame, audio_decoder.get_codec_ctx(),
audio_decoder.get_stream());
}
}
}
av_packet_unref(pkt);
return 0;
}
int VideoProcessor::process_frame(AVFrame *frame, AVCodecContext *codec_ctx,
AVStream *stream) {
// 检查输入帧是否有效
if (!frame || !frame->data[0]) return -1;
int width = codec_ctx->width;
int height = codec_ctx->height;
AVPixelFormat fmt = static_cast<AVPixelFormat>(frame->format);
if (!sws_ctx || frame->format != video_fmt) {
// 判断是否需要重新创建转换上下文
if (sws_ctx) {
sws_freeContext(sws_ctx);
sws_ctx = nullptr;
}
video_fmt = static_cast<AVPixelFormat>(frame->format);
sws_ctx = sws_getContext(frame->width, frame->height, video_fmt, width,
height, AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr,
nullptr, nullptr);
if (!sws_ctx) return -2;
last_image = QImage(width, height, QImage::Format_RGB888);
}
// 准备目标缓冲区
uint8_t *dst_data[1] = {last_image.bits()}; //返回图像首地址
int dst_lineszie[1] = {static_cast<int>(
last_image.bytesPerLine())}; //返回每行像素所占字节数(用于行对齐)
// 执行图像格式转换
int ret = sws_scale(sws_ctx,
frame->data, // 输入图像数据指针数组(如 YUV)
frame->linesize, // 输入每行步长(每个平面)
0, // 从第几行开始
height, // 处理多少行
dst_data, // 输出图像数据指针数组
dst_lineszie // 输出每行步长
);
if (frame->pts != AV_NOPTS_VALUE) {
video_pts = frame->pts * av_q2d(stream->time_base);
}
return (ret > 0) ? 0 : -3;
}
int AudioProcessor::process_frame(AVFrame *frame, AVCodecContext *codec_ctx,
AVStream *stream) {
int ret = 0;
if (!is_init || !frame || !codec_ctx) {
qDebug() << "Audio not initialized or invalid frame/context.";
return -1;
}
// 获取输入通道数和样本数
int in_channels = codec_ctx->ch_layout.nb_channels;
int in_samples = frame->nb_samples;
// 分配输出缓冲区
int out_buf_size = av_samples_get_buffer_size(nullptr, in_channels,
in_samples, target_fmt, 1);
if (out_buf_size < 0) {
qDebug() << "Failed to get output buffer size.";
return -1;
}
uint8_t *out_buf = static_cast<uint8_t *>(av_malloc(out_buf_size));
if (!out_buf) {
qDebug() << "alloc buf failed";
return -1;
}
// 格式转换(重采样)
uint8_t *out[] = {out_buf};
int conv_samples = swr_convert(
swr_ctx, //音频重采样上下文
out, //输出缓冲区数组,out[i] 是第 i 个通道的指针
in_samples, //输出的最大采样数(每个通道)
const_cast<const uint8_t **>(
frame->data), //输入缓冲区数组,指向解码帧每个通道的 PCM 数据
in_samples //输入帧中每个通道的采样数
);
if (conv_samples < 0) {
qDebug() << "swr_convert failed.";
av_free(out_buf);
return -1;
}
if (frame->pts != AV_NOPTS_VALUE) {
audio_pts = frame->pts * av_q2d(stream->time_base);
}
// 推送音频数据到播放队列
ret = SDL_QueueAudio(dev_id, out_buf, out_buf_size);
if (ret < 0) {
qDebug() << "SDL_QueueAudio failed:" << SDL_GetError();
av_free(out_buf);
return -1;
}
// 释放临时
av_free(out_buf);
return 0;
}
小问题
开发过程中遇到的一些问题吧,这一块的主要流程还是比较清晰的,打开文件-文件解复用-音视频解码-显示视频/播放音频-同步控制
首先是配置了cmake, 要开启 AUTOMOC AUTOUIC,设置好 Qt 和 FFmpeg 的依赖路径,源文件清晰列出,其实算是第一次写正式的cpp项目所以折腾了一会,然后要用qtcreator做ui的设计,pro文件,路径搞对,ui文件自动生成.h文件有点小坑,cmake是在src中找对应的cpp文件的ui文件的,同时要在cpp中#include "ui_xxx.h" 且调用了 setupUi(this)才会生成.h文件在build中,以路径啥的要搞对
ffmpeg的函数使用的话头文件需要用extern "C"包进来,因为cpp会对函数进行重命名(name mangling)而c不会,ffmpeg提供的库是c的符号
本来想一起吧音频视频的播放实现,然后一直
segmentation fault (core dumped),这真是最最最令人崩溃的bug了,cmake配置好了后单步调试好方便,以前都是手动cout的啥的,但是在工程里就不方便了,现在是真的调试真香了,发现在QApplication初始化的时候就出问题了,就找找然后是SDL的不兼容问题就先删了先把视频的播放实现了。实现了音视频的同步播放,之前不行的sdl处理音频删了后重新写不知道怎么又行了
音视频同步,通过视频的pts和音频的clock加上threshold实现了同步,我的video_pts始终大于audio_clock,原因:video_pts 表示这帧应当被播放的绝对时间,audio_pts 是当前已送入 SDL 播放队列的最后一帧时间戳,audio_clock = audio_pts - delay 表示正在播放的音频的真实时间位置,delay代表尚未播放的时间。
webrtc推流
主要流程
- 首先是WebRTCConnection类,这推流端使用libdatachannel封装的一个WebRTC会话控制器,主要负责PeerConnection 管理(会话建立、STUN 服务器、ICE 流程),SDP的生成和处理,媒体轨道创建和绑定H.264视频,视频帧的打包发送。
在设定本地的媒体轨道的时候是参考examples里的方法绑定的rtp分包器,这才和浏览器的协议匹配起来。
WebRTCConnection::WebRTCConnection(bool is_caller) {
rtc::Configuration config;
// 添加 STUN server
config.iceServers.emplace_back("stun:stun.l.google.com:19302");
peer_connection = std::make_shared<rtc::PeerConnection>(config);
// 连接状态变化
peer_connection->onStateChange([](rtc::PeerConnection::State state) {
qDebug() << "PeerConnection state: " << static_cast<int>(state);
});
// ICE 状态变化
peer_connection->onGatheringStateChange(
[](rtc::PeerConnection::GatheringState state) {
qDebug() << "ICE Gathering: " << static_cast<int>(state);
});
// 收到本地 SDP
peer_connection->onLocalDescription([this](const rtc::Description& desc) {
qDebug() << "onLocalDescription";
std::string sdp = std::string(desc);
if (on_local_description_) {
on_local_description_(sdp);
}
});
peer_connection->onLocalCandidate([this](const rtc::Candidate& cand) {
qDebug() << "onLocalCandidate";
if (on_local_candidate_) {
on_local_candidate_(std::string(cand), cand.mid());
}
});
peer_connection->onTrack([this](std::shared_ptr<rtc::Track> track) {
qDebug() << "onTrack";
const auto& desc = track->description();
if (dynamic_cast<const rtc::Description::Video*>(&desc)) {
qDebug() << "[WebRTC] Remote video track added. MID = "
<< QString::fromStdString(track->mid());
// 设置接收视频帧的回调
track->onFrame([this](const rtc::binary& data, rtc::FrameInfo info) {
if (on_remote_frame_) {
on_remote_frame_(data);
}
});
}
});
peer_connection->onIceStateChange([](rtc::PeerConnection::IceState state) {
qDebug() << "ICE state: " << static_cast<int>(state);
});
if (is_caller) {
create_video_track(); // 保留这行即可
}
}
void WebRTCConnection::create_offer() {
peer_connection
->setLocalDescription(); // 触发 onLocalDescription 回调 创建本地sdp
}
void WebRTCConnection::create_video_track() {
rtc::Description::Video video("video", rtc::Description::Direction::SendRecv);
video.addH264Codec(96);
video_track = peer_connection->addTrack(video);
// ✅ 构建 RTP 配置
uint32_t ssrc = 20001206; // 可以随机一个
std::string cname = "video"; // 一般用于 RTCP 的同步字段
std::string msid = "stream1"; // media stream ID
uint8_t payload_type = 96;
uint32_t clock_rate = 90000; // H264 标准时钟频率
rtp_config_ = std::make_shared<rtc::RtpPacketizationConfig>(
ssrc, cname, payload_type, clock_rate);
packetizer_ = std::make_shared<rtc::H264RtpPacketizer>(
rtc::NalUnit::Separator::Length,
rtp_config_);
video_track->setMediaHandler(packetizer_);
}
void WebRTCConnection::set_remote_description(const std::string& sdp,
bool is_offer) {
qDebug() << "set remote sdp";
rtc::Description::Type type =
is_offer ? rtc::Description::Type::Offer : rtc::Description::Type::Answer;
rtc::Description desc(sdp, type);
peer_connection->setRemoteDescription(desc);
auto remote_desc = peer_connection->remoteDescription();
if (remote_desc) {
std::string sdp = static_cast<std::string>(*remote_desc);
qDebug() << "[Full Remote SDP]\n" << QString::fromStdString(sdp);
}
}
void WebRTCConnection::send_video_frame(const rtc::binary& frame_data,
const rtc::FrameInfo& info) {
// qDebug() << "send callback";
if (!video_track || !video_track->isOpen()) return;
// QString hex;
// for (size_t i = 0; i < std::min<size_t>(frame_data.size(), 16); ++i) {
// hex += QString::asprintf("%02X ", static_cast<uint8_t>(frame_data[i]));
// }
// qDebug() << "发送帧 HEX:" << hex;
video_track->sendFrame(frame_data, info);
}
void WebRTCConnection::add_ice_candidate(const std::string& candidate,
const std::string& sdp_mid) {
if (!peer_connection) return;
rtc::Candidate cand(candidate, sdp_mid);
cand.resolve();
peer_connection->addRemoteCandidate(cand);
std::cout << "Added ICE candidate:" << cand.candidate() << std::endl;
}
- 回调函数的一些设置和程序调用,
on_local_description_是用来生成并且发送本地的sdp,on_local_candidate_是用来生成并且发送本地的ice。
连接信令服务器并创建 Offer,WebSocket 连接成功后:webrtc_->create_offer(); 触发 PC 生成本地 SDP,从而走到上面的两个回调。
摄像头采集,frame_ready 槽函数只是做本地 UI 显示,set_webrtc_callback(...) 里把编码好的 H.264 数据和对应 FrameInfo 交给 webrtc_->send_video_frame(...),由 libdatachannel 的 H264 packetizer 封包后通过 SRTP 发送。
void MainWindow::on_btnStartWebRTC_clicked() {
if (camera_) {
qDebug() << "摄像头已在运行";
return;
}
// 1. 创建 WebRTCConnection(推流方)
webrtc_ = new WebRTCConnection(true);
webrtc_->on_local_description_ = [=](const std::string &sdp) {
if (!ws_connected_) {
qWarning() << "WebSocket 尚未连接,跳过 SDP 发送";
return;
}
QJsonObject msg;
msg["type"] = "offer";
msg["sdp"] = QString::fromStdString(sdp);
QJsonDocument doc(msg);
qDebug() << "发送 SDP Offer";
ws_.sendTextMessage(QString::fromUtf8(doc.toJson()));
ws_.flush();
};
webrtc_->on_local_candidate_ = [=](const std::string &candidate,
const std::string &mid) {
QJsonObject msg;
msg["type"] = "candidate";
msg["candidate"] = QString::fromStdString(candidate);
msg["sdpMid"] = QString::fromStdString(mid);
msg["sdpMLineIndex"] = 0; // 你可以适配实际值
msg["usernameFragment"] = ""; // libdatachannel 不使用 ufrag
QJsonDocument doc(msg);
qDebug() << "发送本地 ICE candidate";
ws_.sendTextMessage(QString::fromUtf8(doc.toJson()));
ws_.flush();
};
webrtc_->on_remote_frame_ = [](const rtc::binary &data) {
qDebug() << "收到远端帧,大小:" << data.size();
};
// 2. 启动摄像头并连接到 WebRTC 推流
camera_ = new CameraCapture(this);
connect(camera_, &CameraCapture::frame_ready, this, [=](const QImage &img) {
if (!img.isNull()) {
ui->labelLocalVideo->setPixmap(QPixmap::fromImage(img).scaled(
ui->labelLocalVideo->size(), Qt::KeepAspectRatio,
Qt::SmoothTransformation));
}
});
camera_->set_webrtc_callback(
[=](const rtc::binary &data, const rtc::FrameInfo &info) {
if (webrtc_) webrtc_->send_video_frame(data, info);
});
camera_->start();
// 3. 建立 WebSocket 信令连接(成功后 create_offer)
connect_signaling_server();
qDebug() << "摄像头与 WebRTC 推流已启动";
}
void MainWindow::connect_signaling_server() {
connect(&ws_, &QWebSocket::connected, this, [=]() {
ws_connected_ = true;
qDebug() << "WebSocket signaling connected";
if (webrtc_) {
qDebug() << "创建 SDP offer";
webrtc_->create_offer();
}
});
connect(&ws_, &QWebSocket::disconnected, this, [=]() {
ws_connected_ = false;
qDebug() << "WebSocket signaling disconnected";
});
connect(&ws_, &QWebSocket::textMessageReceived, this,
&MainWindow::on_signaling_message);
ws_.open(QUrl("ws://localhost:8888"));
}
- camera类,需要先启动线程,从cap获取数据,本地预览要先把图像格式从bgr转为rgb构造QImage再通过frame_ready发送给qt。推流需要的把图像编码为H264的格式,通过
send_webrtc_frame_回调交给WebRTCConnection::send_video_frame()。
CameraCapture::CameraCapture(QObject* parent)
: QThread(parent), running_(false) {}
CameraCapture::~CameraCapture() {
stop();
wait();
if (h264encoder_) {
delete h264encoder_;
h264encoder_ = nullptr;
}
}
void CameraCapture::stop() {
QMutexLocker locker(&mutex_);
running_ = false;
}
void CameraCapture::run() {
cv::VideoCapture cap(0);
if (!cap.isOpened()) {
qDebug() << "摄像头启动失败";
return;
}
running_ = true;
int width = static_cast<int>(cap.get(cv::CAP_PROP_FRAME_WIDTH));
int height = static_cast<int>(cap.get(cv::CAP_PROP_FRAME_HEIGHT));
int fps = 30;
H264Encoder h264encoder(width, height, fps);
while (running_) {
cv::Mat bgr_frame;
cap >> bgr_frame;
if (bgr_frame.empty()) continue;
// 本地播放(注意:不能直接改 bgr_frame 否则影响编码)
cv::Mat rgb_frame;
cv::cvtColor(bgr_frame, rgb_frame, cv::COLOR_BGR2RGB);
QImage img(rgb_frame.data, rgb_frame.cols, rgb_frame.rows, rgb_frame.step,
QImage::Format_RGB888);
emit frame_ready(img.copy());
auto encoded = h264encoder.encode(bgr_frame);
if (!encoded.data.empty() && send_webrtc_frame_) {
rtc::binary frame(encoded.data.begin(), encoded.data.end());
rtc::FrameInfo info(static_cast<uint32_t>(encoded.timestamp_us / 1000));
send_webrtc_frame_(frame, info);
}
QThread::msleep(1000 / fps);
}
cap.release();
}
void CameraCapture::set_webrtc_callback(
std::function<void(const rtc::binary&, const rtc::FrameInfo&)> cb) {
send_webrtc_frame_ = std::move(cb);
}
- H264编码器,初始化的部分,先要找H264的codec,并且为其分配上下文,并且设置上下文信息。关闭Annex-B,意味着输出为 AVCC(length-prefixed),适合 WebRTC 的 H264RtpPacketizer(NalUnit::Separator::Length)。并且设定好从BGR转为YUV420P的sws_ctx
通过sws_scale,将相机得到的bgr转换并且写到frame_data转换为yuv420p的格式,然后通过send_frame和receive_packet得到h264编码后的AVPacket,并将其中的data作为结果与时间结合得到输出,通过之前设定的回调send_video_frame送给RTPPacketization来进行分包发送。
YUV420P 是一种未压缩的像素格式,H.264 是一种视频压缩编码标准。H.264 编码器的输入通常就是 YUV420P(或其它 YUV 变种),输出则是压缩后的小体积码流。
struct EncodedFrame {
std::vector<std::byte> data;
uint64_t timestamp_us;
};
H264Encoder::H264Encoder(int width, int height, int fps)
: width_(width), height_(height), fps_(fps) {
initialize();
}
H264Encoder::~H264Encoder() {
if (codec_ctx_) avcodec_free_context(&codec_ctx_);
if (frame_) av_frame_free(&frame_);
if (sws_ctx_) sws_freeContext(sws_ctx_);
}
void H264Encoder::initialize() {
const AVCodec* codec = avcodec_find_encoder(AV_CODEC_ID_H264);
if (!codec) throw std::runtime_error("找不到 H.264 编码器");
codec_ctx_ = avcodec_alloc_context3(codec);
if (!codec_ctx_) throw std::runtime_error("无法分配编码上下文");
codec_ctx_->width = width_;
codec_ctx_->height = height_;
codec_ctx_->time_base = AVRational{1, fps_};
codec_ctx_->framerate = AVRational{fps_, 1};
codec_ctx_->pix_fmt = AV_PIX_FMT_YUV420P;
codec_ctx_->bit_rate = 5000000;
codec_ctx_->gop_size = 10;
codec_ctx_->max_b_frames = 0;
AVDictionary* opts = nullptr;
av_dict_set(&opts, "preset", "ultrafast", 0);
av_dict_set(&opts, "tune", "zerolatency", 0);
av_dict_set(&opts, "profile", "baseline", 0);
av_dict_set(&opts, "x264-params", "annexb=0", 0); // 关闭Annex-B
if (avcodec_open2(codec_ctx_, codec, &opts) < 0) {
av_dict_free(&opts);
throw std::runtime_error("无法打开编码器");
}
av_dict_free(&opts);
frame_ = av_frame_alloc();
frame_->format = codec_ctx_->pix_fmt;
frame_->width = codec_ctx_->width;
frame_->height = codec_ctx_->height;
if (av_frame_get_buffer(frame_, 32) < 0) {
throw std::runtime_error("分配帧缓冲失败");
}
sws_ctx_ = sws_getContext(width_, height_, AV_PIX_FMT_BGR24, width_, height_,
AV_PIX_FMT_YUV420P, SWS_BILINEAR, nullptr, nullptr,
nullptr);
if (!sws_ctx_) throw std::runtime_error("无法创建图像转换上下文");
}
EncodedFrame H264Encoder::encode(const cv::Mat& bgr_frame) {
if (!frame_) throw std::runtime_error("frame 未初始化");
if (av_frame_make_writable(frame_) < 0) {
throw std::runtime_error("frame 不可写");
}
const uint8_t* src_data[] = {bgr_frame.data};
int src_linesize[] = {static_cast<int>(bgr_frame.step)};
sws_scale(sws_ctx_, src_data, src_linesize, 0, height_, frame_->data,
frame_->linesize);
frame_->pts = pts_++;
if (avcodec_send_frame(codec_ctx_, frame_) < 0) {
throw std::runtime_error("发送帧失败");
}
AVPacket* pkt = av_packet_alloc();
EncodedFrame result;
while (true) {
int ret = avcodec_receive_packet(codec_ctx_, pkt);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break;
if (ret < 0) {
av_packet_free(&pkt);
throw std::runtime_error("接收编码包失败");
}
result.data.insert(result.data.end(),
reinterpret_cast<std::byte*>(pkt->data),
reinterpret_cast<std::byte*>(pkt->data + pkt->size));
av_packet_unref(pkt);
}
av_packet_free(&pkt);
auto now = std::chrono::steady_clock::now();
result.timestamp_us = std::chrono::duration_cast<std::chrono::microseconds>(
now.time_since_epoch())
.count() -
g_base_timestamp;
return result;
}
推流功能的主要步骤
- 新建WEBRTCConnection类,封装PeerConnection,SDP,ICE,包括一些回调函数
- 利用websocket作为信令通道,交换sdp和ice,实现caller和callee的配对,sdp包括后续交换的一些规范,ice是ip与端口与内网穿透相关
- 启动摄像头,捕获摄像头帧,使用ffmpeg进行h264编码,RTP分包后再传输
碰到的一些问题
- h264编码的格式需要与浏览器解码方面的统一,
- rtp分包这个东西,我原本自己是不知道的,然后建立连接后弄了好几天都无法发送,问了很久ai,百度谷歌啥的找了总是不行(还是有点小众),然后去看了libdatachannel的examples源代码,按照源代码的流程改了下建立连接与发送数据的代码才成功,看源码还是有用很多啊