前十三篇解决的是“如何把桌面OBS搬到鸿蒙上”。从这篇开始,问题换了一个:搬上来之后,鸿蒙能给OBS哪些桌面给不了的能力?答案落在两台设备之间的关系上——手机不再是一个缩小版OBS,而是制作主机的镜头和导播台。下面这条链路全程用两部Mate真机验证,涉及同一个HAP分角色运行、自研RCTM帧协议、Camera Kit到AVCodec的零拷贝推流,以及手机把录制指令下发到平板、状态再回流到手机的过程。
一、同包分角色:一个HAP装出两个应用
两部设备安装同一个HAP,打开后却是两个完全不同的界面:
平板或2in1设备进入完整OBS控制台,场景、来源、混音器、推流按钮都在,来源列表里多出一个桌面版没有的“无线机位”选项;手机只显示“无线机位”角色页,填IP、点“开始机位”,后置摄像头画面就会出现在平板画布上。角色页下方还挂着“导播台”,连上平板后可以切换平板场景,录制和直播按钮跟随平板实况翻转。
架构上,ArkTS入口根据deviceInfo.deviceType分叉:手机分支直接return,RawFileExtractor、obs_startup、插件加载、混音轨一行都不执行;平板和2in1分支才启动libobs,并加载无线机位接收源。这样处理并非单纯为了省资源。真机验证过,手机端应用切到后台后,没有长时任务支撑,十几秒内就会被系统以signal:9回收。一个跑着libobs、攥着音视频采集的进程,在手机上天然活不长。再加上产品事实——做直播的人不会蹲在手机前剪多机位,制作主机需要大屏、续航和前台稳定性,手机的真实角色就是镜头和遥控器。
第一次验证分叉时,手机直接落进角色页,hilog里没有一行libobs日志。一个包里装着两个应用,靠设备形态各就各位。
二、Service Collaboration远程相机:一条被文档判死刑的捷径
做“手机相机进平板OBS”时,官方看起来留了一条直路:跨端协同服务,文档标题写着“PC/2in1跨端调用Phone相机”。第一版方案就是在相机插件里埋device_id="@remote"哨兵,枚举相机时优先挑CAMERA_CONNECTION_REMOTE类型,碰一碰信令obs-knock://camera一到就自动建远程相机源。
真机先把这条路堵死:两台同账号设备在超级终端里协同状态正常,但OBS的相机枚举里永远没有REMOTE条目。翻文档才看清,CAMERA_CONNECTION_REMOTE设备只有系统级应用挂得上,三方应用枚举不到。
退一步用协同服务对外接口createCollaborationServiceMenuItems也不行。逐字读完正文会发现,它的语义是拍照:拉起对端相机、拍一张、把照片ArrayBuffer回传。onState回调一次回一张静态图,没有连续帧流;旧的createCollaborationCameraMenuItems已标记deprecated。拍照语义不等于连续流,系统能力也不等于三方可用,@remote哨兵方案整体退役。
这个判定值得单独记住:官方文档里“支持跨端调用Phone相机”和“三方应用能把手机相机做成连续视频源”之间,隔着整整一个实现语义的距离。
三、自研RCTM:20字节帧头管够
排除系统捷径后,问题回到最朴素的状态:手机把H.264码流通过局域网TCP推给平板,平板边收边解边画。设备之间没有现成流协议,就自己定一个20字节帧头:
- magic u32 'RCTM'
- flags u32 bit0=关键帧提示 bit1=配置帧(SPS/PPS)提示
- pts i64 采集时间戳(CLOCK_MONOTONIC 纳秒)
- size u32 负载长度
- payload Annex-B H.264 访问单元
复制代码
设计时纠结过是否把SPS/PPS外置成独立配置帧、是否兼容RTP、是否预留音频帧位。最后取舍服从一个原则:协议是给自己两端读的,可读可查可改即可;但字节序必须提前讲清楚,全用big-endian,时间戳必须带上,将来对音频、对多机位都往这个头里加flag,不用再动结构。
还有一个不显然的决定:大流量走socket,信令走软总线。碰一碰的协同文本通道很优雅,但拿它传视频是灾难,base64一进一出膨胀33%,吞吐也顶不住2Mbps的实时码流。所以软总线只负责“碰一下,开始传”这类控制语义,字节的洪水一律走TCP。
四、发送端:让相机直接长在编码器上
手机侧第一反应是复用平板已验证的相机采集插件:Camera Kit进ImageReceiver读回NV12,CPU转RGBA,再进libobs,最后接编码器。这个方案没有尝试就被否决了——它意味着每一帧都要GPU buffer回读CPU、转格式、再拷贝给编码器。手机只做采集、编码、发走三件事,中间任何一次不必要的CPU拷贝都在增加发热和丢帧概率。
HarmonyOS的AVCodec给了第二条路:surface输入模式的编码器自带一个可写入的NativeWindow,相机VideoOutput直接渲染到这个窗口上,帧就长在编码器输入队列里。发送链路可以概括为:Camera Kit的NORMAL_VIDEO会话通过surface直连OH_VideoEncoder的H.264输入,编码输出回调拿到Annex-B访问单元后进入深度30的内存队列,满则丢旧,再由专职socket线程加上RCTM帧头发出。
这里有个NDK接口细节:OH_VideoEncoder_GetSurface返回的是OHNativeWindow*,而相机CreateVideoOutput要的是字符串形式的surfaceId,中间需要用OH_NativeWindow_GetSurfaceId换算一次。这个“窗口到surfaceId字符串”的翻译层,是鸿蒙把图形生产者和消费者解耦后的粘合缝,第一次遇到容易愣住。
另一个关键约束:编码器输出回调里绝对不能碰socket。onNewOutputBuffer跑在codec工作线程上,网络一卡,整个编码器就停了。回调只做一件事——把码流字节拷进深度30的队列,通知专职发送线程;队列满就丢最旧的。直播语义下,新鲜画面永远比完整录像重要。
手机侧代码刻意不依赖libobs,整个是纯NDK加POSIX socket,编在napi桥模块里,由ArkTS角色页用三个函数唤起:startCameraSender(host, port, bitrate)、stopCameraSender()、cameraSenderStatus()。
五、接收端:Maleoon硬解、几何协商与那个熟悉的RGBA
平板侧是一个正经OBS插件harmony-remotecam,注册harmony_remote_camera异步视频源。它的世界分成三个线程,交接全用队列:accept线程监听TCP:25890,解析RCTM帧后进入深度4的pending队列,满则丢旧;解码器需要输入时从队列取;OH_VideoDecoder硬解输出NV12后,在回调里CPU转RGBA,再交给obs_source_output_video。
眼熟的读者会问:为什么又是NV12到RGBA的CPU转换?这是第13篇相机源的老毛病。Maleoon驱动拒绝libobs多平面路径的GL_R8加GL_RG16纹理上传,NV12直接交给libobs会整帧黑。同一条教训第二次生效:这块GPU上,packed RGBA是唯一见过光的色彩路径。连转换用的BT.709系数矩阵(298/459/55/137/541那组乘256整数)都是从相机插件原样搬来的。工程做到后面,复用不是优雅,是纪律。
解码器这边有个AVCodec特性很贴心:onStreamChanged回调会把真实几何随流带出来,包括宽高、stride和slice_height。发送端编码1920x1080时,stride可能比宽度大、slice_height比高度大,这是硬件对齐的结果。按几何画布盲猜偏移必然花屏,用回调里的权威值一次就对。这个回调签名是(codec, format, userData),比另外两个少一个index参数,写的时候编译器会提醒一遍。
验收策略也值得一提:接收端不依赖发送端就能单独验证。写了一个200行的mac脚本rctm-test-sender.py,用ffmpeg造testsrc画面,按起始码切NAL,打上RCTM帧头往平板TCP一推。解码渲染链路的技术风险——Maleoon硬解能否打开、色彩是否正确、帧能否进画布——当天中午全部排掉,手机发送端晚上才上场。双端功能,先造假对手,把真手的风险拆单。
六、导播台:一个字母的bug与状态自己流回来
机位打通后,手机角色页顺势长出导播台。这次跨设备通道不再自建,视频洪水才需要socket,控制指令量太小,直接走应用协同连接abilityConnectionManager:手机作为source发起connect,平板OBS作为sink在onCollaborate里acceptConnect,之后双向只发小JSON。
链路一次就通,平板日志打出DirectorLink: accepted connect (sessionId=100)。但手机界面卡死在“连接中”,场景列表怎么都不出来。双向打日志排查,平板侧一行warn把真相原样打在脸上:receiveMessage without text payload,后面跟着{"sessionId":101,"eventType":"receiveMessage","msg":"{\"action\":\"status\",\"index\":-1}"}。命令明明送到了,载荷就在info对象里,但字段名是msg,双端代码读的都是message。于是每一条到达的消息都在messageOf()里被读成空串并丢弃,静默得像什么都没发生。一个字母,双端同犯。
教训压成两句话:EventCallbackInfo的文本字段是msg,SDK声明文件第255行白纸黑字;所有“读不到就当没有”的else分支,必须打warn并带原始载荷。本次5分钟破案,全靠这条日志把对象整个吐了出来。静默丢弃是分布式调试的头号大敌。
修完后,那个夜晚最漂亮的一幕出现了:手机点“开始录制”,平板真的开始录,第8篇那套output代码原样复用;一秒后手机按钮自己翻成“停止录制”。这不是前端乐观更新,而是平板的stats.recording=true坐上了每秒一次的状态心跳,流回来把按钮刷成了真。再点一次,平板停录,手机按钮翻回。命令下行、状态上行,双向都验真。
七、顺手抓的两个真bug
第一个是悬空指针。手机首推失败,日志报GetOutputCapability failed。查FindBackCamera:从GetSupportedCameras拿到设备数组,*out = cameras做结构体浅拷贝,然后DeleteSupportedCameras释放数组。Camera_Device.cameraId是个const char*,指向刚被释放的字符串,下游CreateCameraInput拿着野指针查能力,不挂才怪。修法与第13篇相机洋葱同族:跨释放边界的字符串必须自己own一份。发送器结构里加char device_id_buf[64]做深拷贝,生命周期跟pipeline同始同终。鸿蒙NDK里成对的Get*/Delete*全家桶,都是这种雷,见一个排一个。
第二个是测试脚本的4:4:4。rctm-test-sender.py第一推零帧,原因是ffmpeg里testsrc默认yuv444p,撞上baseline profile后编码器拒绝打开。加-pix_fmt yuv420p解决。工具链的坑不在主链路上,但一样能让人误判“功能没通”。
八、离生产级还差什么
这套“无线机位加导播台”目前是演示级成立,生产级还差几件事,原文列出了任务清单和闭环状态:
机位断线自动重连已闭环:指数退避1/2/4/8/16/30秒,断流期间相机和编码器不重启,重连成功清积压帧并重发SPS/PPS配置帧,真机实证制作端被杀后第6次重试自动恢复解码。
机位带声音已闭环:RCTM帧头兑现预留的flags位AUDIO=0x4,手机OH_AudioCapturer采48kHz立体声,OH_AudioEncoder编AAC-ADTS,与视频交错推同一条socket,平板侧硬解AAC进libobs混音器,录成片验到手机麦克风声,峰值-17dB。这一路排掉六个连环坑,包括ADTS/raw封装、喂帧门控死锁、跨设备时钟域、会话锚点复位、空推热循环饿死编码器、僵尸peer卡accept。
机位进录制出口的实证已闭环:testsrc模拟机位流进画布后开始录制,成片h264加aac抽帧验到测试图案,采集、解码、合成、编码、封装全出口打通;RTMP推流口同一composite下游,还待补一次人工验流。
多机位并发尚未实测:两个手机等于两个源、两个端口,架构天然支持,但“分布式机位”的叙事要站住,这一验跑不掉。
九、API锚定与结论
原文对照本机SDK(API 26 sysroot)核实了关键API:Camera Kit NORMAL_VIDEO会话(API 12+)、OH_VideoEncoder surface输入(API 11+)、OH_NativeWindow_GetSurfaceId(API 12+)、OH_VideoDecoder硬解加onStreamChanged几何协商(API 11+)、deviceInfo.deviceType形态分叉(API 10+)、abilityConnectionManager应用协同连接(API 18+,SystemCapability.DistributedSched.AppCollaboration)、distributedDeviceManager可信设备发现(API 10+)。EventCallbackInfo.msg字段名以SDK声明文件第255行为准。
回到最初的问题:把OBS搬上鸿蒙到底为了什么?如果只是“能在鸿蒙上跑”,前十三篇已经够了。这篇给出的答案是,鸿蒙重新分配了“一台直播工作站”的构成。在桌面,机位意味着采集卡或UVC摄像头加一根线;在这里,同一个局域网里的同账号设备,点两下屏幕就能成为一个机位、一块遥控屏。在桌面,多设备协同是第三方软件用私有协议互猜;在这里,设备发现、协同连接、触碰传文件是系统级地基,人人可取。手机不是OBS的缩小版,它是制作主机的镜头和导播台——这个形态,桌面给不了。 |