前言:为什么我们需要“二次编码”?
在安防监控、教育直播或庭审录像等场景中,我们往往不满足于仅仅把视频“拉下来看”。我们经常面临以下高阶需求:
-
版权与取证:需要在原始视频流上叠加实时的“当前时间”或“执法记录仪ID”,且必须烧录在画面中(Hardcode),防止被篡改。
-
品牌露出:在直播转推过程中,添加频道Logo或动态滚屏文字。
-
多流合一:将摄像头画面与本地桌面、或AI算法分析出的边框结果合成后,生成新的流推送到服务器。
本文将结合SmartMediakit(大牛直播SDK),详细拆解如何在Windows平台实现一个全能中间件”:它既是播放器(拉流解码),又是渲染引擎(GDI+绘制水印),更是推流器(二次编码推RTMP+本地录像)。
核心架构设计
我们的目标是打造一个闭环的视频处理管道(Pipeline):
[RTSP/RTMP源]
⬇️ (拉流)
[SmartPlayer播放器] -> 解码 -> [RGB32/I420数据回调]
⬇️
[GDI+ 水印渲染引擎] -> 生成ARGB水印Bitmap
⬇️
[SmartPublisher推流器] -> 图层混合 (视频层 + 水印层)
⬇️ (编码)
[RTMP推流] & [本地MP4录像]
基于您提供的源码,我们将重点分析三个核心模块:数据回调桥接、GDI+动态水印渲染、以及多图层推流配置。
一、 播放器端:获取“纯净”的RGB数据
首先,我们需要配置播放器,使其不直接上屏渲染(或在渲染的同时),将解码后的原始数据抛出来。
在 CSmartPlayerDlg::OnBnClickedButtonPlay 中,我们通过 SetVideoFrameCallBack 设置回调:
// 1. 初始化SDK
player_api_.SetVideoSizeCallBack(player_handle_, GetSafeHwnd(), SP_SDKVideoSizeHandle);
// 2. 设置回调格式为 RGB32,方便后续GDI+处理(虽然I420效率更高,但RGB处理水印更方便)
// 如果需要高性能,建议使用I420,但此处演示RGB32的通用性
player_api_.SetVideoFrameCallBack(player_handle_, NT_SP_E_VIDEO_FRAME_FORMAT_RGB32,
this, SM_SDKVideoFrameHandleV2);
// 3. 启动播放
if (NT_ERC_OK != player_api_.StartPlay(player_handle_))
{
AfxMessageBox(_T("播放器失败!"));
return;
}
关键回调函数 OnVideoFrameHandle: 这是连接“播放”与“推流”的桥梁。每当播放器解出一帧画面,就会调用此函数。我们在这里将数据“喂”给推流端:
void CSmartPlayerDlg::OnVideoFrameHandle(NT_HANDLE handle, NT_UINT32 status,
const NT_SP_VideoFrame* frame)
{
if (nullptr == frame) return;
// 加锁保护,防止推流端正在析构或停止
std::unique_lock<std::recursive_mutex> lock(push_handle_mutex_);
if (!is_pushing_ && !is_push_recording_ && !is_push_previewing_)
return;
if (GetPushHandle() == nullptr) return;
// 组装 NT_PB_Image 数据结构,准备投递给推流SDK
NT_PB_Image image;
memset(&image, 0, sizeof(image));
image.width_ = frame->width_;
image.height_ = frame->height_;
if (NT_SP_E_VIDEO_FRAME_FORMAT_RGB32 == frame->format_)
{
image.format_ = NT_PB_E_IMAGE_FORMAT_RGB32;
image.plane_[0] = frame->plane0_;
image.stride_[0] = frame->stride0_;
image.plane_size_[0] = frame->stride0_ * frame->height_;
}
else if (NT_SP_E_VIDEO_FRAME_FROMAT_I420 == frame->format_)
{
// 处理I420格式... (代码略,原理同上)
image.format_ = NT_PB_E_IMAGE_FORMAT_I420;
// ...赋值plane 0, 1, 2
}
else
{
return;
}
// 核心步骤:将从播放器拿到的视频帧,作为“第0层”(视频底层)投递给推流器
// index_ = 0 表示这是最底层的视频画面
int index_ = 0;
push_api_.PostLayerImage(push_handle_, 0, index_, &image, 0, NULL);
}
二、 渲染引擎:GDI+ 实现动态时间水印
静态图片水印很容易(直接加载PNG即可),但动态时间水印(如“2026-01-21 14:00:01”)需要实时生成图片。
为此,我们封装了一个 NTWatermarkRenderer 类(源自 nt_watermark_renderer.cpp),利用 Windows GDI+ 绘制文字并转换为 ARGB 数据。
1. GDI+ 初始化与字体缓存
频繁创建字体对象极其消耗性能,因此我们采用缓存策略:
void NTWatermarkRenderer::SetTextWatermarkFont(const LOGFONT& lf, COLORREF color)
{
// 如果字体参数变了,才重新创建 Gdiplus::Font,否则复用
if (memcmp(&cached_lf_, &lf, sizeof(LOGFONT)) != 0 || cached_color_ != color || !has_cached_font_)
{
cached_lf_ = lf;
cached_color_ = color;
cached_font_.reset(CreateGdiplusFontFromLOGFONT(lf)); // 将LOGFONT转为Gdiplus::Font
has_cached_font_ = (cached_font_ != nullptr);
}
}
2. 核心渲染逻辑:文字转ARGB Bitmap
这个函数是动态水印的核心,它动态计算文字宽,绘制并返回内存块。
std::shared_ptr<nt_watermark_argb_image> NTWatermarkRenderer::RenderTextWatermark(
int width, int height, const std::wstring& text)
{
if (!is_gdiplus_initialized_ || width <= 0 || height <= 0) return nullptr;
// 如果未传入文本,自动生成当前系统时间字符串
std::wstring w_text = text.empty() ? MakeCurrentTimeString() : text;
Gdiplus::Font* font = has_cached_font_ ? cached_font_.get() : nullptr;
// ... 防御性代码:如果字体为空,使用默认字体 ...
// 1. 测量文字大小,防止显示不全
Gdiplus::Bitmap measure_bmp(1, 1, PixelFormat32bppARGB);
Gdiplus::Graphics g_measure(&measure_bmp);
Gdiplus::RectF boundingBox;
g_measure.MeasureString(w_text.c_str(), -1, font, Gdiplus::PointF(0, 0), &boundingBox);
int final_w = max(width, (int)boundingBox.Width + 20);
int final_h = max(height, (int)boundingBox.Height + 10);
final_w = (final_w + 3) & ~3; // 4字节对齐优化
// 2. 创建画布
Gdiplus::Bitmap bitmap(final_w, final_h, PixelFormat32bppARGB);
Gdiplus::Graphics g(&bitmap);
// 3. 设置高质量渲染参数(抗锯齿)
g.Clear(Gdiplus::Color(0, 0, 0, 0)); // 背景完全透明
g.SetTextRenderingHint(Gdiplus::TextRenderingHintClearTypeGridFit);
g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
// 4. 绘制文字
Gdiplus::SolidBrush brush(Gdiplus::Color(255, GetRValue(cached_color_), GetGValue(cached_color_), GetBValue(cached_color_)));
Gdiplus::RectF layoutRect(5.0f, 0, (Gdiplus::REAL)final_w - 5.0f, (Gdiplus::REAL)final_h);
// 垂直居中绘制
Gdiplus::StringFormat format;
format.SetLineAlignment(Gdiplus::StringAlignmentCenter);
g.DrawString(w_text.c_str(), -1, font, layoutRect, &format, &brush);
// 5. 锁定位图数据,拷贝出来返回
Gdiplus::BitmapData data;
if (bitmap.LockBits(nullptr, Gdiplus::ImageLockModeRead, PixelFormat32bppARGB, &data) == Gdiplus::Ok)
{
auto res = std::make_shared<nt_watermark_argb_image>(data.Width, data.Height);
res->stride_ = data.Stride;
res->data_.reset(new NT_BYTE[data.Stride * data.Height]);
memcpy(res->data_.get(), data.Scan0, data.Stride * data.Height);
bitmap.UnlockBits(&data);
return res;
}
return nullptr;
}
三、 推流端:多图层叠加与编码
有了视频源和水印源,接下来就是“组装”。SmartPublisher SDK 提供了强大的图层(Layer)概念。
1. 配置图层结构
在开始推流前,我们需要定义图层顺序。通常顺序是:外部视频(底层) -> 图片水印(中层) -> 文字水印(顶层)。
代码位于 CSmartPlayerDlg::SetLayersConfig:
bool CSmartPlayerDlg::SetLayersConfig()
{
auto push_handle = GetPushHandle();
if (push_handle == nullptr) return false;
layer_conf_wrappers_.clear();
int index = 0;
AddExternalVideoFrameLayer(index);
AddImageWatermarkLayer(index);
AddTextWatermarkLayer(index);
// 将配置应用到SDK
std::vector<const NT_PB_LayerBaseConfig*> base_confs;
for (const auto& wrapper : layer_conf_wrappers_)
{
base_confs.push_back(wrapper->getBase());
}
if (!base_confs.empty())
{
// 核心调用:一次性设置所有图层参数
auto ret = push_api_.SetLayersConfig(push_handle, 0,
base_confs.data(), base_confs.size(), 0, nullptr);
return (NT_ERC_OK == ret);
}
return true;
}
2. 定时刷新文字水印
为了让时间动起来,我们需要一个定时器(Timer),每隔几百毫秒生成一个新的时间Bitmap,投递给推流端。
在 CSmartPlayerDlg::OnTimer 中:
void CSmartPlayerDlg::OnTimer(UINT_PTR nIDEvent)
{
if (nIDEvent == TEXT_WATERMARK_REFRESH_TIMER_ID)
{
// 只有在推流且启用水印时才刷新
if (IsPusherRunning() && is_text_watermark_enabled_ && text_watermark_layer_index_ >= 0)
{
UpdateTextWatermarkLayer();
}
return;
}
// ... 其他Timer处理
}
UpdateTextWatermarkLayer 的实现:
void CSmartPlayerDlg::UpdateTextWatermarkLayer()
{
auto push_handle = GetPushHandle();
if (!push_handle || text_watermark_layer_index_ < 0 || !watermark_renderer_)
return;
// 1. 生成当前时间字符串
std::wstring time_str = MakeWatermarkTimeStr();
// 2. 调用渲染器生成 ARGB Bitmap
watermark_renderer_->SetTextWatermarkFont(text_watermark_lf_, text_watermark_color_);
auto watermark = watermark_renderer_->RenderTextWatermark(
text_watermark_region_.width_,
text_watermark_region_.height_,
time_str);
if (watermark)
{
// 3. 将新生成的水印图片投递到指定的 Layer Index
NT_PB_Image image;
memset(&image, 0, sizeof(image));
image.format_ = NT_PB_E_IMAGE_FORMAT_ARGB; // 注意格式是ARGB
image.width_ = watermark->width_;
image.height_ = watermark->height_;
image.plane_[0] = watermark->data_.get();
image.stride_[0] = watermark->stride_;
image.plane_size_[0] = watermark->stride_ * watermark->height_;
// index: 0 (reserved), layer_index: text_watermark_layer_index_
push_api_.PostLayerImage(push_handle, 0, text_watermark_layer_index_, &image, 0, NULL);
}
}
四、 开启推流与录像
最后一步,就是启动推流。这里我们不仅推送到RTMP服务器,还利用SDK的并发能力,同时录制到本地MP4。
bool CSmartPlayerDlg::StartPush(const std::string& url)
{
// ... 前置检查与Handle打开 ...
// 1. 设置通用的编码参数(H.264, 码率, 帧率等)
if (publisher_handle_count_ < 1)
{
SetCommonOptionToPublisherSDK();
}
// 2. 设置RTMP URL
if (NT_ERC_OK != push_api_.SetURL(push_handle, url.c_str(), NULL))
return false;
// 3. 启动推流
if (NT_ERC_OK != push_api_.StartPublisher(push_handle, NULL))
return false;
// ... 状态更新 ...
return true;
}
// 独立的录像控制
void CSmartPlayerDlg::OnBnClickedButtonPushRec()
{
// ...
// 设置录像文件名规则(自动追加时间)
NT_PB_RecorderFileNameRuler rec_name_ruler = { 0 };
rec_name_ruler.file_name_prefix_ = "push_rec";
rec_name_ruler.append_date_ = 1;
rec_name_ruler.append_time_ = 1;
push_api_.SetRecorderFileNameRuler(push_handle_, &rec_name_ruler);
// 启动本地录像(不影响RTMP推流,二者共享编码数据,效率极高)
push_api_.StartRecorder(push_handle_, NULL);
}
总结与开发建议
通过上述步骤,我们实现了一个功能完备的“视频流处理工作站”。
技术要点总结:
-
数据源:利用 SmartPlayer 的
SetVideoFrameCallBack获取原始RGB/YUV数据,这是二次处理的基础。 -
动态水印:GDI+ 是 Windows 下处理文字渲染的最佳伴侣,但要注意
LockBits的性能和内存对齐。使用 Timer 动态刷新 PostLayerImage 实现了时间戳跳动。 -
图层管理:SmartPublisher 的图层设计非常灵活,将视频、图片、文字分层处理,SDK内部会自动进行 Alpha 混合,极大地简化了开发者的工作量。
-
编码效率:虽然我们用了 RGB 回调(方便 GDI+),但在推流 SDK 内部,它会高效地转换颜色空间并进行 H.264/H.265 编码,且支持硬编码,保证了低 CPU 占用。
避坑指南:
-
线程安全:播放器的回调是在 SDK 的内部线程,而 UI 操作(如点击按钮停止推流)在主线程。务必像代码中那样使用
std::recursive_mutex锁住 Handle,防止在回调过程中 Handle 被释放引发 Crash。 -
GDI+ 性能:不要在每一帧视频回调里都去 RenderTextWatermark,那样 CPU 会炸。通常 1 秒刷新 2-4 次文字水印就足够流畅了。
-
内存泄露:GDI+ 的 Bitmap 和 SDK 的回调数据都要注意生命周期管理,代码中使用了
std::shared_ptr和std::unique_ptr是很好的实践。
希望这篇博文能帮助你在 Windows 音视频开发中少走弯路。如果有关于大牛直播 SDK 的具体配置问题,欢迎留言交流!