简介这是一份面向GStreamer开发者的实战示例包聚焦如何利用tee元素将单路视频流同时分发到预览、截图和录像三条处理链解决可控录像中多路并行处理的常见需求。资源以Qt工程形式提供包含C源码、工程配置、运行界面截图及辅助脚本共17个文件压缩包仅1.18MB便于直接下载查看整体结构清晰。目前已有4333人学习。包内代码清晰演示了fakesink预览、appsink截帧、filesink录制的组合方式并引入了queue缓冲避免各分支处理速度不一致导致的丢帧以及x264enc编码、mp4mux封装等关键环节。通过阅读工程与截图可快速理解在QtGStreamer中如何利用pad-added信号动态连接pads、监听appsink的new-sample信号提取帧数据并将截图、录像功能集成到自己的应用中。适合有一定GStreamer基础、希望在Qt中实现视频预览与录制并行处理的开发者参考。1. gstreamer里用tee做可控录像预览、截图、录像为什么必须靠它先说实话第一次接触gstreamer里的tee元素时我差点被它绕晕。一个看起来只是“把数据流复制一份”的元件凭什么成为预览截图录像这条链路的枢纽直到我在一个需要同时做三件事的采集项目里被 pipelines、pad、probe 折磨了几个通宵才真正理解tee的设计价值——它根本不是在“复制数据”而是在帮你把一条单一的数据流拆成多条各自独立的处理路径且互不阻塞。这个标题解决的是多媒体应用里最典型的一组诉求用户要实时预览摄像头画面要能随手截图还要按需录像。很多人第一反应是“分别打开三次设备”或者“在同一个回调里又编码又显示”结果不是帧率崩了就是录像文件花屏再不然截图延迟到怀疑人生。tee的答案非常优雅源端只拉取一次数据从tee分出预览支路、截图支路、录像支路每条支路自带队列、编码器、muxer或回调。你不需要操心源设备被谁抢走不必手工做帧拷贝gstreamer内部用引用计数管理缓冲tee只是把自己收到的每个缓冲再push给下游代价极小。对于新手tee是“好懂但不敢用”的元件对于熟手tee的坑集中在队列深度、阻塞模式、probe回调时机这三处。这篇文章我按自己做采集方案的习惯来写先讲tee的分流原理和选型再给一套可直接复现的预览截图录像pipeline接着拆参数最后写我踩过的坑。你不需要听完理论才动手但动完手再回来看原理你会发现它能帮你省下大量排查时间。2. tee分流模型与pipeline拓扑一文看懂预览、截图、录像如何共享同一条流2.1 tee在gstreamer里的位置不是“复制数据”而是“分发引用”gstreamer的pipeline是一棵数据树从source如v4l2src、videotestsrc拉数据经过一系列filter如capsfilter、videoconvert、encodebin最后到达sink如autovideosink、filesink。tee这个element比较特殊它有1个sink pad和多个src pad默认有request pad你可以动态请求新的src pad每个src pad都能接一条完整的下游分支。关键点在于tee并不会真的把每个buffer深拷贝一份。它做的事情是增加buffer的引用计数然后同步或异步地把同一个buffer依次push到每个src pad对应的下游。也就是说你从tee拉出去的三条路内存里其实共享同一份图像数据。这带来两个重要结论只要一条支路不阻塞其它支路永远不会因为数据被谁“消费掉”而拿不到数据——不存在“谁先抢到谁用”的问题。真正需要关注的不是数据重复而是“支路阻塞”问题如果有人录像时写磁盘卡了整条pipeline都会被反压预览也会卡。这就是为什么每条支路都要独立加queue。从拓扑上说常见做法是把tee放在videoconvert之后、任何sink之前。这样source采集到的原始格式比如NV12、YUY2先统一转换为RGB或I420再送入tee分流。好处是录像是I420走硬件/软件编码预览是RGB走显示截图是RGB保存PNG/JPG三者各取所需而源端格式转换只做一次。2.2 最小分流拓扑videotestsrc → tee → 三条支路先抛开摄像头我用gstreamer自带的videotestsrc做测试源搭一条最简单的tee分流。这样你可以快速验证tee的可行性再替换成真实设备。gst-launch-1.0 \ videotestsrc patternball num-buffers300 ! \ video/x-raw,formatI420,width640,height480,framerate30/1 ! \ tee namet \ t. ! queue ! videoconvert ! autovideosink \ t. ! queue ! videoconvert ! pngenc ! multifilesink location/tmp/frame.png \ t. ! queue ! videoconvert ! x264enc ! mp4mux ! filesink location/tmp/test.mp4注意这条命令只是“能跑”不是“可用方案”。我故意放进来是为了让你看到tee最核心的接法tee namet定义一个tee元件然后t.分别引出三条支路。每条支路先用queue隔断再接各自的下游处理。逻辑说明tee namet是给tee取名为t之后用t.引用它的第N个src pad。gstreamer会自动创建request pad。第一条支路queue ! videoconvert ! autovideosink负责预览。这里的queue是缓冲防止显示速度慢拖垮整条链路。第二条支路queue ! videoconvert ! pngenc ! multifilesink负责截图。pngenc是编码器multifilesink会把每一帧写成不同文件。如果用location/tmp/frame.png它会把所有帧都写到同一个文件我这里是演示用实际截图不应这样写。第三条支路queue ! videoconvert ! x264enc ! mp4mux ! filesink负责录像。x264enc是软编码器mp4mux封装filesink落盘。参数说明patternball让测试画面是一个滚动球方便肉眼确认画面在动。num-buffers300是总帧数300帧在30fps下就是10秒跑完pipeline自己结束。我在三条支路都放了videoconvert因为tee出来的原始格式是I420预览sink可能直接支持但PNG编码和x264编码对格式要求不同统一转换最省心。这条命令跑起来你应该能看到预览窗口/tmp/test.mp4在10秒后生成/tmp/frame.png会写300个文件因为每帧都写这显然不对。所以要进入下一节把截图从“每帧都写”改成“按需写入”。2.3 可变支路设计截图与录像为什么不能挂在同一个pad上很多人会把截图和录像“合并处理”“反正都是编码嘛我截图时就把视频压缩成图片录像时就把图片堆成视频”。这在gstreamer里是行不通的或者说代价非常高。因为截图是“取某一帧的完整图像数据”录像需要的是一段连续帧序列经过编码器压缩。如果你把截图逻辑放在录像编码器的下游你能拿到的只有编码后的压缩数据不是原始帧放在编码器上游你就要在每次截图时复制一整帧并转成RGB这会干扰编码器的帧率控制。更合理的设计是给截图单独一条支路并用probe或外部信号控制“什么时候截”。我们常见的做法有这几种在截图支路的sink pad上挂一个buffer probeprobe回调里判断是否到了截图时刻是就把buffer拷贝一份或者用gst_buffer_map把数据映射出来存成文件。用appsrc作为截图支路的动态输入源或者用appsink拉出数据由外部代码控制。用intervideosink/intervideosrc把画面桥接到另一个pipeline处理。我一般推荐方案1因为它改动最小你的主pipeline里给截图支路挂一个appsink或fakesink在probe里操作buffer。下面的章节我们直接用gst-launch-1.0给一个“能截图的demo”再换成Python代码做可控版。3. 可控录像的三条关键路径队列隔离、编码参数、muxer封装3.1 别让录像拖死预览队列depth、leaky、timeout怎么配这是tee方案里最值得花时间调的三个参数也是几乎所有翻车的根源。tee本身不背锅背锅的是你在他下游用了共享状态。先说queue。queue本质上是一个线程安全的缓冲队列默认行为是如果队列满了上游会阻塞等待。如果你录像支路的编码器性能不足或者磁盘写入太慢queue会堆满然后tee的push就会被卡住整条pipeline都慢下来——预览也跟着卡。这时候你需要的是让这条慢支路丢帧而不是阻塞上游。queue max-size-buffers2 max-size-time0 max-size-bytes0 leakydownstream参数含义max-size-buffers2队列里最多保存2个buffer超过就触发leaky策略。leakydownstream当队列满时把队列里最旧的buffer丢掉即丢帧而不是阻塞上游。这样录像支路如果跟不上只会少录几帧预览完全不受影响。max-size-time0和max-size-bytes0关闭按字节和时间限制只按帧数限制这样控制粒度最直观。另外还有一个容易被忽略的参数queue silent。如果你不设silentqueue每隔一段时间会打印一次状态到终端调试时没问题放到服务里就是刷屏。queue max-size-buffers2 leakydownstream silenttrue对于预览支路我一般不用leaky因为预览本来就应该尽量看到最新帧如果显示设备跟不上队列里的旧帧会造成串扰。预览支路的queue可以用leakyupstream意思是在队列满时丢掉新进来的buffer保证永远显示最新帧。但注意leakyupstream对应的是“丢新帧”跟录像的“丢旧帧”正好相反。截图支路不需要queue因为截图是一次性读取当前帧队列只会增加延迟。我通常直接接fakesink挂probe读数据这样截图支路是一个“几乎不占资源”的旁路。3.2 截图支路该用什么sinkfakesink probe是王道如果你在gstreamer命令行里想“按一次键截一张图”只能写应用。先讲清楚原理截图支路的最终sink不是用来显示的而是要“吞掉”buffer并触发你的回调。fakesink是最干净的吞buffer元件它什么都不做但会调用下游probe如果挂了的话。我们把这个sink pad上的probe当作截图信号。一个可行的命令行协议是截图支路接到fakesink同时在fakesink的sink pad上挂一个gst_pad_add_probe。probe类型是GST_PAD_PROBE_TYPE_BUFFER每次有buffer流过就调用回调。回调里拿到GstSample再从中获取GstBuffer用gst_buffer_map读出像素数据。判断当前是否需要截图这个可以用全局变量或信号控制。import gi gi.require_version(Gst, 1.0) from gi.repository import Gst, GLib Gst.init(None) pipeline_str videotestsrc patternball num-buffers300 ! video/x-raw,formatI420,width640,height480,framerate30/1 ! tee namet t. ! queue ! videoconvert ! autovideosink t. ! queue ! videoconvert ! fakesink namesnap_sink t. ! queue ! videoconvert ! x264enc ! mp4mux ! filesink location/tmp/test.mp4 pipeline Gst.parse_launch(pipeline_str) snap_sink pipeline.get_by_name(snap_sink) snap_pad snap_sink.get_static_pad(sink) def on_snap_buffer(pad, info, user_data): sample info.get_buffer() if sample is None: return Gst.PadProbeReturn.OK buf sample mapinfo buf.map(Gst.MapFlags.READ) if mapinfo is not None: # 这里把mapinfo.data写入PNG文件 with open(/tmp/snapshot.rgb, wb) as f: f.write(mapinfo.data) buf.unmap(mapinfo) print(snapshot captured) return Gst.PadProbeReturn.OK snap_pad.add_probe(Gst.PadProbeType.BUFFER, on_snap_buffer, None) pipeline.set_state(Gst.State.PLAYING) GLib.MainLoop().run()这段代码去掉了一些细节但核心都在info.get_buffer()在BUFFER probe里拿到的是当前bufferbuf.map()后你可以直接读写像素内存。我这里是写原始I420数据要变成PNG还得用gst_video_info_map或编码器。更规范的做法是让fakesink的pad cap协商成RGB然后你把RGB数据交给pngenc去编码。为了避免过度复杂你在实际项目里可以这样做截图支路用caps强制RGB24然后在probe里把RGB数据写入BMP文件BMP不需要压缩文件头自己写就行。你可能会问为什么不能用filesink直接写帧因为filesink会把所有数据连续写进同一个文件你看到的是乱码。fakesinkprobe是“你自己决定拿哪一帧”的标准答案。这也是为什么标题叫“可控录像”不可控方案可以无脑录但截图的“可控”在tee体系里就是probe。3.3 录像参数x264enc / vaapih264enc 与关键帧间隔、码率录像支路的编码参数直接影响两个体验占用磁盘、播放帧率。在tee分流场景里编码器如果同步工作也会成为支路瓶颈。我先给一套稳妥的软编码参数再提一下硬编码。queue max-size-buffers2 leakydownstream ! \ videoconvert ! \ x264enc bitrate2000 key-int-max30 bframes0 \ speed-presetveryfast tunezerolatency ! \ h264parse ! mp4mux ! filesink location/tmp/test.mp4参数说明key-int-max30每30帧插入一个关键帧I帧。对录制视频来说如果你希望后期能快速拖拽、剪辑关键帧间隔不要太大。默认可能到250帧这会让你在播放器里拖进度条时“卡死”很久。bframes0关闭B帧。B帧能提高压缩率但会引入额外的解码延迟而且如果你的后续处理像“逐帧截图分析”就麻烦了。可控录像优先关闭B帧。speed-presetveryfast牺牲一点压缩率换取编码速度。实时录制时很关键如果用placebo预设编码器会跟不上输入帧率队列就会堆积。tunezerolatency这个tune专门为低延迟场景优化避免编码器累积帧。h264parse把x264enc输出的裸H.264流分包mp4mux才能正确写入。真实摄像头采集时我会把videotestsrc换成v4l2src device/dev/video0再加io-mode2用户指针模式减少拷贝。如果你的平台支持硬件编码用vaapih264enc或v4l2h264enc它们在低功耗设备上表现好得多但要注意硬编码器的参数范围跟x264enc不完全一样。比如vaapih264enc的码率控制是通过bitrate和max-bframes设的同样把bframes设为0关键帧间隔设30。4. 实现一套“预览截图录像”最小完整方案Python tee 信号控制4.1 为什么用Python封装gst-launch无法处理“按需截图”纯粹用gst-launch-1.0你可以做到同时预览和录像但“按一下键截图”这件事命令行根本做不了。因为你需要一个外部事件源比如键盘输入、HTTP请求、MQTT消息来告诉gstreamer“现在截一帧”。这个事件源必须访问到pipeline里的某个pad或element只有应用程序能做到。我常用的做法是gstreamer跑在主线程用一个独立的线程接收截图指令指令到达后设置一个全局标志。probe回调里检查标志如果为真就执行截图并清标志。这样避免了在probe回调里做耗时操作比如RGB转PNG、写磁盘而是在主线程或工作线程里做。下面给一套完整可跑的代码。我用videotestsrc模拟摄像头方便你直接运行换成v4l2src时只改source部分即可。4.2 完整代码键盘触发截图、默认不录、按需启停录像import gi import os import signal import sys import threading gi.require_version(Gst, 1.0) from gi.repository import Gst, GLib Gst.init(None) # 全局状态 snapshot_requested False recording False pipeline None def on_bus_message(bus, msg): if msg.type Gst.MessageType.ERROR: err, dbg msg.parse_error() print(ERROR:, err.message, dbg) GLib.MainLoop().quit() def make_pipeline(): pipeline_str videotestsrc patternball ! video/x-raw,formatI420,width640,height480,framerate30/1 ! tee namet t. ! queue max-size-buffers2 silenttrue ! videoconvert ! autovideosink t. ! queue max-size-buffers2 silenttrue ! videoconvert ! fakesink namesnap t. ! queue max-size-buffers2 leakydownstream silenttrue ! videoconvert ! x264enc bitrate2000 key-int-max30 bframes0 speed-presetveryfast tunezerolatency ! h264parse ! mp4mux ! filesink namerecordsink location/tmp/recording.mp4 return Gst.parse_launch(pipeline_str) def snapshot(pipe): global snapshot_requested, pipeline snapshot_requested True def toggle_recording(pipe): global recording, pipeline recording not recording if recording: # 设置filesink的location为带时间戳的新文件 location f/tmp/rec_{int(time.time())}.mp4 filesink pipeline.get_by_name(recordsink) filesink.set_property(location, location) pipeline.set_state(Gst.State.PLAYING) print(recording to, location) else: # 停止录像但pipeline继续运行 sink pipeline.get_by_name(recordsink) # 这里不直接停pipeline而是动态断开录像支路的pad # 简化只做状态标记真正的pad切换在下一节 print(stop recording (not fully implemented in demo)) # 这个probe挂在snap的sink pad上 def snap_probe(pad, info, user_data): global snapshot_requested if not snapshot_requested: return Gst.PadProbeReturn.OK snapshot_requested False sample info.get_buffer() # 映射buffer buf sample ok, mapinfo buf.map(Gst.MapFlags.READ) if ok: # 这里只是把I420数据写出来真实场景要做RGB转换 with open(/tmp/snapshot.i420, wb) as f: f.write(mapinfo.data) buf.unmap(mapinfo) print(snapshot saved) return Gst.PadProbeReturn.OK pipeline make_pipeline() bus pipeline.get_bus() bus.add_signal_watch() bus.connect(message, on_bus_message) snap pipeline.get_by_name(snap) snap_pad snap.get_static_pad(sink) snap_pad.add_probe(Gst.PadProbeType.BUFFER, snap_probe, None) # 演示用一个定时器模拟外部指令 def request_snapshot(): global snapshot_requested snapshot_requested True return True GLib.timeout_add_seconds(2, request_snapshot) GLib.timeout_add_seconds(5, request_snapshot) pipeline.set_state(Gst.State.PLAYING) print(running... press CtrlC to stop) try: loop GLib.MainLoop() loop.run() except KeyboardInterrupt: print(stopping) finally: pipeline.set_state(Gst.State.NULL)逻辑说明pipeline结构和上一节命令行版本几乎一致只是把截图支路末尾换成fakesink namesnap便于按名字获取。snap_probe在BUFFER触发时检查snapshot_requested标志。如果为True就保存当前buffer。这种设计下你可以在任意线程里把snapshot_requested置Trueprobe会在下一帧到来时响应不影响实时性。录像启停我在代码里只做了状态标记没有真正断开/连接pad因为动态换filesink location需要重构支路。真正的实现稍后讲。4.3 动态停止录像不能直接set_state(NULL)要用pad阻塞或接合录像最容易被想当然的地方是“停止录像时直接让filesink停一下”。在gstreamer里如果你对整个pipeline调用set_state(GST_STATE_NULL)预览也会黑屏。对某个sink调用set_state往往也不行因为sink的状态变化会反向影响上游。正确做法有三种在录像支路的tee src pad上加probe返回GST_PAD_PROBE_REMOVE的同时把该pad与下游queue断开。这是“断流”方式gstreamer官方推荐。在录像支路的queue与编码器之间插入一个identity用identity的drop属性控制是否放行buffer。这种方式最简便但缺点是数据还在流动只是被丢弃浪费编码器CPU。用valve元素。valve是一种“阀门”可以动态切换drop属性为True/False等于用一条命令停止或恢复数据流。我一般用方案1或3。方案3的代码最直观valve pipeline.get_by_name(rec_valve) valve.set_property(drop, True) # 停止录像 valve.set_property(drop, False) # 恢复录像注意valve只是让buffer不再向下传递但编码器、muxer、filesink还在running状态等你重新把drop设为False时编码器会继续编码之后收到的帧。如果你希望“停止录像后文件完整结束”需要用EOS信号或手动发送EOS事件到录像支路。在动态录像里我更习惯用pad probe方案检测到“停止录像”指令时给录像支路发送GST_EVENT_EOS让filesink主动结束文件并关闭。代码量多但最符合“可控”语义。5. tee支路常见翻车点不是tee的锅是你没设队5.1 现象一录像预览就卡去掉录像支路就正常这是第一个让人崩溃的现象预览和录像同时开预览掉到5帧你把录像支路从pipeline里注释掉预览立刻恢复30帧。很多人的第一反应是“tee太耗性能”其实问题几乎都出在录像支路没有queue或queue参数不对。原因是录像支路的x264enc编码一帧可能耗时30毫秒而预览需要33毫秒一帧保持30fps。如果没有queuetee的push调用会直接同步进入x264enc等它编码完才返回。此时videotestsrc拉帧线程被卡住预览自然掉帧。加了queue max-size-buffers2 leakydownstream后tee把buffer丢进queue就立即返回queue后面的编码器慢慢消化队列满了就丢旧帧完美解耦。如果你加了queue还是卡检查leaky方向。很多人把leakyupstream用在录像支路这会导致丢最新帧而不是丢旧帧录像会出现跳秒而且编码器总是拿到不连续的帧x264enc的码率控制会异常。5.2 现象截图出来的图片是绿的、花的或大小不对截图支路如果不指定capsfakesink会跟上游协商成某种格式可能是I420也可能是NV12。你把I420数据当成RGB写BMP自然花绿。解决方法是给截图支路强制RGB格式t. ! queue ! videoconvert ! video/x-raw,formatRGB24 ! fakesink namesnap这样probe里拿到的buffer就是RGB24每像素3字节。如果你写BMP还要补一个54字节的文件头并注意BMP的行宽必须是4字节对齐否则图片底部会斜。另外很多人用gst_buffer_map直接拿buffer的data但这个data是“映射到内存的原始数据”它可能是对齐过的比如每行有padding。用GstVideoMeta能拿到stride信息。在写文件前一定要用GstVideoFrame结构体里的info来读取。简单的做法先gst_video_frame_map它考虑了对齐。以下代码是固定的from gi.repository import GstVideo caps snap_pad.get_current_caps() info GstVideo.VideoInfo.new_from_caps(caps) frame GstVideo.VideoFrame.new(info, buf, GstVideo.CapsFlags.READ) # frame.planes[0] 是Y平面frame.info.size是总大小5.3 现象停止录像文件损坏播放器打不开几乎每个用过filesink的人都遇过录像中途CtrlC文件打不开。这是因为muxer没有收到EOSmp4的moov box没写全。如果你用gst-launch-1.0按CtrlC进程被强杀gstreamer来不及做收尾工作。解决方式在程序里优雅停止。录像需要停止时先给录像支路发送GST_EVENT_EOS等filesink收到EOS后mp4mux会把moov写进文件并发出EOS消息。再让主线程收到EOS后把pipeline置NULL。如果你的录像支路挂在tee上只给那个支路发EOS就够了。发送EOS的代码tee_src_pad pipeline.get_by_name(teesrc_3) # 录像支路的tee src pad tee_src_pad.send_event(Gst.Event.new_eos())注意这个发送是异步的你需要在bus上监听EOS消息来判断文件是否完成。我在实际项目里会额外把录出来的文件重命名或移到其他目录确保不会写到一半的临时文件被误用。5.4 现象硬编码器在tee里“没画面”但单独跑正常如果你用vaapih264enc或v4l2h264enc常常出现预览正常录像文件是黑的。多数情况不是pipeline错了而是硬编码器的输入格式要求。摄像头输出NV12硬编码器可能只接受特定的颜色格式或特定的内存模式。你在录像支路加videoconvert可以解决一部分但硬编码器还要求buffer是DMA-BUF或特定内存类型videoconvert有时不转换内存类型。这时你需要在source和tee之间或者录像支路里显式地转换内存类型比如capsfilter capsvideo/x-raw(memory:DMABuf),formatNV12,width1920,height1080另一个坑是硬编码器对分辨率有对齐要求比如宽高必须是16的倍数。如果你的摄像头是640x480没问题但如果是1280x720需要确认硬编码器支持。报错往往是not-negotiated排查时先用gst-launch-1.0跑一条简单的硬编码测试再用tee一步步加复杂。6. 进阶把截图做成真正的PNG以及如何验证整个链路的实时性6.1 截图从“写裸数据”升级到PNG用appsink或encodebin前面的demo里截图保存的是I420裸数据这在调试时可以看但交付时没人能用。更专业的方案是截图支路直接接一个pngenc然后用一个“只保存一帧”的手段把PNG编码器的输出保存下来。最简单可靠的做法是用appsink拉取PNG数据t. ! queue ! videoconvert ! video/x-raw,formatRGB ! pngenc ! appsink namesnap_sink在Python里你从appsink拉取buffer写入文件。注意pngenc的输出buffer是PNG文件内容不是图片数据。appsink拉取时用try_pull_sample拿到sample里的buffer直接写文件。如果截图频率很低每秒1次这个方案可行。如果截图频率高比如每秒10次建议把截图支路的pngenc和filesink动态拉起来平时droptrue需要截图时设dropfalse同时使用multifilesink每次生成新文件。但这样会浪费编码器资源。大部分应用里结合tee和probe会更灵活probe里复制出一个buffer然后在另一个线程里交给pngenc编码不阻塞主pipeline。6.2 验证方法用gst-launch跑固定的帧数检查输出文件时长写完整个方案后你需要验证三件事预览是否流畅肉眼加上录屏工具。如果掉帧明显检查四条queue的leaky设置和编码器speed-preset。截图是否正确截图后立即打开看比较截图画面和预览画面的时间差。人为地让视频源显示一个计时器比如用clockoverlay在源端打上系统时间截图后对比时间戳。录像文件是否完整用gst-discoverer-1.0检查文件时长和编码信息。gst-discoverer-1.0 /tmp/recording.mp4如果发现文件时长比预期短说明录像支路丢帧严重如果时长正常但画面断裂说明leaky方向错了。我用clockoverlay的验证方式是这样的在source后面加一个clockoverlay让画面显示系统时间。然后分别在预览截图和录像帧里读出时间计算差值。差值大于500毫秒说明你可能在某个支路里用了不合理的队列缓冲或编码延迟高。硬件编码器有时为了提高压缩率会缓存几帧这是正常的但你至少要知道它慢在哪里。6.3 我的习惯tee支路里的sink必须能单独“拔出”做了这么多年采集方案我养成了这样一个习惯所有接在tee上的支路它的第一个元素一定是queue而且这个queue必须有名字。这样在动态控制时我能通过拆掉queue与tee之间的pad连接让这条支路彻底“离线”而不是仅仅停止sink。这样做的好处是不用担心编码器状态混乱下次重新接上时一切从干净状态开始。具体操作是拿到tee的src pad调用gst_pad_unlink之后把支路的sink pad也置为NULL状态。等你需要恢复录像再重新gst_pad_link并设置到PLAYING。这个流程在Python里也就二十行代码但它把“可控”做到了极致——你随时可以切断录像支路预览和截图不受影响文件优雅关闭。6.4 参数速查表三支路的推荐配置下表是我在多数1080p30摄像头场景下的默认配置你根据设备性能适当调整。支路queue配置编码/处理关键参数预览max-size-buffers2 leakyupstream silenttruevideoconvert autovideosink无截图max-size-buffers1不设leakyvideoconvert pngenc appsink指定video/x-raw,formatRGB24录像max-size-buffers2 leakydownstream silenttruevideoconvert x264enc h264parse mp4mux filesinkkey-int-max30 bframes0 tunezerolatency speed-presetveryfast其中截图支路的queue我不建议设leaky因为截图讲究“要哪一帧就哪一帧”如果丢帧你按快门后可能截到的是下一帧或上上一帧。设max-size-buffers1且不设leaky的原理是queue满了会阻塞tee的push但这只会影响截图支路自己不会阻塞其它支路因为tee对每个pad是独立阻塞的。所以即使截图支路慢一点点也顶多让录像慢几毫秒可以接受。6.5 写在最后tee不难难的是别让你的“可控”变成一锤子买卖我见过太多人把tee用成了“一条命令跑起来截图录像都行”却从不考虑停止时的收尾、动态切换、异常恢复。真正踩过坑之后你会发现tee的价值不在于它复制了数据而在于它给了你“按需断开任意支路”的权力。你只要在每个支路入口设置好queue把截图支路做成旁路把录像支路的EOS处理干净这个方案就能像模块一样嵌入任何采集系统。希望我这篇文章能帮你少走几个弯路也希望你能把主动权握在自己手里——你的pipeline不该被一块tee牵着走。本文还有配套的精品资源点击获取