Windows端RTSP/RTMP播放器实现回调RGB数据、动态水印叠加与二次推流录像技术实践

0 阅读1分钟

​前言:为什么我们需要“二次编码”?

在安防监控、教育直播或庭审录像等场景中,我们往往不满足于仅仅把视频“拉下来看”。我们经常面临以下高阶需求:

  1. 版权与取证:需要在原始视频流上叠加实时的“当前时间”或“执法记录仪ID”,且必须烧录在画面中(Hardcode),防止被篡改。

  2. 品牌露出:在直播转推过程中,添加频道Logo或动态滚屏文字。

  3. 多流合一:将摄像头画面与本地桌面、或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);
}

总结与开发建议

通过上述步骤,我们实现了一个功能完备的“视频流处理工作站”。

技术要点总结:

  1. 数据源:利用 SmartPlayer 的 SetVideoFrameCallBack 获取原始RGB/YUV数据,这是二次处理的基础。

  2. 动态水印:GDI+ 是 Windows 下处理文字渲染的最佳伴侣,但要注意 LockBits 的性能和内存对齐。使用 Timer 动态刷新 PostLayerImage 实现了时间戳跳动。

  3. 图层管理:SmartPublisher 的图层设计非常灵活,将视频、图片、文字分层处理,SDK内部会自动进行 Alpha 混合,极大地简化了开发者的工作量。

  4. 编码效率:虽然我们用了 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_ptrstd::unique_ptr 是很好的实践。

希望这篇博文能帮助你在 Windows 音视频开发中少走弯路。如果有关于大牛直播 SDK 的具体配置问题,欢迎留言交流!