RTMP 直播推流 Demo(一)—— 项目配置与视频预览

音视频编解码系列目录:

Android 音视频基础知识
Android 音视频播放器 Demo(一)—— 视频解码与渲染
Android 音视频播放器 Demo(二)—— 音频解码与音视频同步
RTMP 直播推流 Demo(一)—— 项目配置与视频预览
RTMP 直播推流 Demo(二)—— 音频推流与视频推流

前面的视频播放器 Demo 是在拉流端进行音视频解码,接下来介绍的 RTMP 直播推流的 Demo 是推流端进行音视频编码。Android 设备作为推流端将摄像头拍摄的图像上传至服务器,在 PC 端通过 FFmpeg 提供的 ffplay 工具或者 EVPlayer 拉流播放视频。

1、项目结构

首先来看直播架构示意图:

2024-1-4.直播推流示意图

主要有三个角色:

  1. 推流端:安卓设备,使用摄像头采集图像,麦克风采集声音,通过 RTMP 协议将音视频流传输到服务器上
  2. 服务器:一般是 NGINX 服务器,需要进行 RTMP 的相关配置以接收推流端的数据
  3. 拉流端:可以是移动设备也可以是 PC,能播放 RTMP 流即可。后续演示时会在 PC 端通过 FFmpeg 提供的 ffplay 工具拉流

除了上述三个重要角色,还会有房间服务模块,服务器的管理与 Web 播放就是通过 HTTP 协议了:

2024-1-4.直播推流服务器搭建

2、开源库的使用与项目配置

在推流过程中,我们会使用几个开源库:

  1. 服务器端:NGINX 服务器需要下载 NGINX 源码,在 Linux 环境编译并启动。此外,还需要支持 RTMP 通信的 RTMP 模块编译进 NGINX 中
  2. Android 推流端:需要三个开源库:
    • 视频编码需要 x264
    • 音频编码需要 faac
    • RTMP 通信需要 RTMPDump

我们首先来看服务器如何配置。

编译环境:Alibaba Cloud Linux 3,NDK 17,NGINX 1.18,RTMP Module 1.2.1,RTMPDump 2.3,FFmpeg 4.2.2。

2.1 配置 NGINX 服务器

下载源码

需要下载 NGINX 源码以及 RTMP 模块源码。先下载 NGINX 源码并解压:

wget https://nginx.org/download/nginx-1.18.0.tar.gz
tar -xvf nginx-1.18.0.tar.gz

然后下载 NGINX RTMP 模块并解压得到 nginx-rtmp-module-1.2.1 目录:

wget https://codeload.github.com/arut/nginx-rtmp-module/tar.gz/v1.2.1
tar xvf v1.2.1

编译 NGINX 源码

进入 NGINX 根目录,运行脚本进行编译:

./configure --prefix=./output --add-module=../nginx-rtmp-module-1.2.1

参数说明:

  • –prefix 指定编译产物的输出目录,./output 表示在当前目录的 output 文件夹下,如果该目录不存在会自动创建
  • –add-module 指定添加一个模块,这里我们添加的是 rtmp-module,在上级目录的 nginx-rtmp-module-1.2.1 文件夹下

由于 NGINX 依赖 gcc、PCRE、OpenSSL、zlib 这些库,缺少其一编译就会报错,比如缺少 PCRE:

checking for PCRE library ... not found
checking for PCRE library in /usr/local/ ... not found
checking for PCRE library in /usr/include/pcre/ ... not found
checking for PCRE library in /usr/pkg/ ... not found
checking for PCRE library in /opt/local/ ... not found./configure: error: the HTTP rewrite module requires the PCRE library.
You can either disable the module by using --without-http_rewrite_module
option, or install the PCRE library into the system, or build the PCRE library
statically from the source with nginx by using --with-pcre=<path> option.

此时需要安装 PCRE:

yum install -y pcre pcre-devel

依赖库的具体安装方法可以参考以下文章:

  • Centos:centos7安装Nginx
  • Ubuntu:ubuntu下安装nginx时依赖库zlib,pcre,openssl安装方法

编译成功之后,会有类似的输出:

2024-1-5.NGINX编译成功

当然目前并不会在 nginx-1.18.0 目录下生成 output 目录以及可执行文件,需要在执行完安装命令之后才能看见该文件夹。

安装 NGINX

接着安装 NGINX:

make && make install

报错:

cc1: all warnings being treated as errors
make[1]: *** [objs/Makefile:1339: objs/addon/nginx-rtmp-module-1.2.1/ngx_rtmp_eval.o] Error 1
make[1]: Leaving directory '/root/AndroidNDK/nginx-1.18.0'
make: *** [Makefile:8: build] Error 2

原因是将警告当成了错误处理,需要修改 /nginx-1.18.0/objs/Makefile 的编译参数:

# 去掉下面的 -Werror 选项
CFLAGS =  -pipe  -O -W -Wall -Wpointer-arith -Wno-unused-parameter -Werror -g

再次执行安装命令可以成功安装。

配置 NGINX 服务器

成功安装 NGINX 服务器后需要对其进行配置,修改 /nginx-1.18.0/output/conf/nginx.conf 文件:

user  root; # 指定 root 权限,否则可能会因权限不足而启动失败
worker_processes  1; # 工作在哪个进程#error_log  logs/error.log; # 错误日志
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;#pid        logs/nginx.pid;events {worker_connections  1024; # 支持最大的直播人数
}# 对 RTMP 协议的配置
rtmp {server {listen 1935; # 1935 端口application myapp {live on; # 打开直播drop_idle_publisher 5s; # 闲置 5s 后断开连接}}
}# 对 HTTP 协议的配置
http {server {listen  8081;location /stat {rtmp_stat all;rtmp_stat_stylesheet stat.xsl;}location /stat.xsl {root /root/AndroidNDK/nginx-rtmp-module-1.2.1/;}location control {rtmp_control all;}location /rtmp_publisher {root /root/AndroidNDK/nginx-rtmp-module-1.2.1/test;}location / {root /root/AndroidNDK/nginx-rtmp-module-1.2.1/test/www;}}
}

nginx.conf 是使用 NGINX 自定义的语法 Nginx Configuration Language 编写的,并不属于任何传统的编程语言。

配置时需要注意几点:

  • location 标签内 root 后面配置的路径要换成你实际的路径,比如你的 nginx-rtmp-module-1.2.1 文件夹的绝对路径是 /root/AndroidNDK/nginx-rtmp-module-1.2.1/,那么你配置的 root 后面就要跟这个路径,而不是我给出的 /root/nginx-rtmp-module-1.2.1/

  • 如果你因为配置错误而修改了 nginx.conf 文件,并且 NGINX 服务器已经启动了,那么你需要先停掉 NGINX 服务器再重新启动它才可使修改生效:

    [root@frank nginx-1.18.0]# ./output/sbin/nginx -s stop
    [root@frank nginx-1.18.0]# ./output/sbin/nginx
    

启动 NGINX 服务器

在 NGINX 根目录 nginx-1.18.0 下执行可执行文件 nginx 启动服务器:

如果显示 8081 端口被占用了,可以 kill 掉占用 8081 端口的进程:

# 通过该命令查询到占用 8081 端口的进程号为 28764
netstat -tunlp|grep 8081
# kill 掉 28764 号进程解除 8081 端口的占用
kill -9 28764

这时候去访问 NGINX 服务器地址。如果你使用的是云服务器,那么就访问服务器的公网 IP + 端口号。例如我的 Linux 服务器公网 IP 为 118.24.126.13,那么你就去访问 118.24.126.13:8081;如果你是在本地 Linux 虚拟机上搭建的服务器,那么就访问本地服务器地址,如 192.168.31.39:8081。成功访问的页面如下:

2024-1-5.NGINX成功访问

由于环境不同,配置复杂可能还会有各种各样的问题,这里我再列举一些问题和解决方法:

  • 主机能 ping 通虚拟机,但是虚拟机 ping 不到主机:参考Ubuntu虚拟机无法ping通windows,反之可以的解决办法

  • 如果使用的云服务器,还需要配置服务器的安全组,把 1935 和 8081 端口打开:
    2024-2-26.阿里云配置开放端口

  • 假如在配置脚本时忘记在第一行指定 user root,访问后台页面时可能会显示 nginx 403 forbid。查看 nginx-1.18.0/output/logs/error.log 发现是权限问题:

    [error] 3848#0: *1 open() "/root/nginx-rtmp-module-1.2.1/stat.xsl" failed (13: Permission denied), client: x.x.x.x, server: , request: "GET /stat.xsl HTTP/1.1", host: "118.24.126.13:8081", referrer: "http://118.24.126.13:8081/stat"
    [error] 3848#0: *1 open() "/root/nginx-rtmp-module-1.2.1/test/www/favicon.ico" failed (13: Permission denied), client: x.x.x.x, server: , request: "GET /favicon.ico HTTP/1.1", host: "118.24.126.13:8081", referrer: "http://118.24.126.13:8081/stat"
    

    通过命令查看哪些用户运行了 NGINX:

    ps -ef | grep nginx
    ps aux | grep "nginx: worker process" | awk '{print $1}'
    

    以上两个命令运行其一即可,得到的结果是 root 和 nobody:

    root      3896     1  0 15:56 ?        00:00:00 nginx: master process ./bin/sbin/nginx
    nobody      3898  3896  0 15:56 ?        00:00:00 nginx: worker process
    root      4068  4036  0 17:55 pts/2    00:00:00 grep --color=auto nginx
    

    由于所有命令都是在 root 用户下进行的,因此需要在脚本中指定 user 为 root

2.2 RTMPDump 编译与配置

RTMP 是一个协议,而 RTMPDump 是处理 RTMP 协议数据的开源库:

  • RTMP(Real Time Messaging Protocol),实时消息传输协议,是基于 TCP 的应用层协议
  • RTMPDump 是用 C 语言开发的处理 RTMP 流媒体的开源工具包。它能够单独使用进行 RTMP 的通信, 也可以集成到 FFmpeg 中通过 FFmpeg 接口来使用 RTMPDump。它封装了 Socket 建立 TCP 通信,实现了 RTMP 数据的收发。借助 RTMPDump 可以通过调用 C 的 API 的方式实现推流与拉流,而无需考虑 RTMP 底层细节(类似于 OkHttp 库与 HTTP 协议的关系)

由于 RTMPDump 的源码并不多,并且我们会对其源码稍加修改,因此就不在 Linux 服务器编译出它的库之后再放入 AS 中使用,而是直接放入 AS 中编译。

首先,在 RTMPDump 的官网找到下载页面,下载最新的 2.3 版本 rtmpdump-2.3.tgz,解压后会看到一个 librtmp 目录。先查看该目录下的 Makefile,了解如何编译。关键信息如下:

OBJS=rtmp.o log.o amf.o hashswf.o parseurl.olibrtmp.a: $(OBJS)log.o: log.c log.h Makefile
rtmp.o: rtmp.c rtmp.h rtmp_sys.h handshake.h dh.h log.h amf.h Makefile
amf.o: amf.c amf.h bytes.h log.h Makefile
hashswf.o: hashswf.c http.h rtmp.h rtmp_sys.h Makefile
parseurl.o: parseurl.c rtmp.h rtmp_sys.h log.h Makefile

要编译出 librtmp.a 这个静态库,需要 OBJS 变量定义的几个目标文件,而编译目标文件所需的源文件也在后续给出了。因此,我们将 librtmp 目录下的这些文件,拷贝到 AS 项目的 /src/main/cpp/librtmp 下,并新建 CMakeLists.txt 用来编译静态库:

cmake_minimum_required(VERSION 3.22.1)# 将源文件定义为 rtmp_src 变量
file(GLOB rtmp_src *.c)
# 用 C 不是 C++ 了,因为 RTMP 是用 C 写的
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DNO_CRYPTO")
# 声明如下源文件编译出来的库文件名称为 librtmp.a
add_library(rtmp STATIC ${rtmp_src})

我们注意到在 set 命令中通过 -D 参数声明了一个宏 NO_CRYPTO,如果不添加该参数编译会报错:

[1/1] Re-running CMake...
-- Configuring done
-- Generating done
-- Build files have been written to: F:/Code/Android/VideoLive/app/.externalNativeBuild/cmake/debug/x86_64
[1/8] Building C object src/main/cpp/librtmp/CMakeFiles/rtmp.dir/log.c.o
[2/8] Building C object src/main/cpp/librtmp/CMakeFiles/rtmp.dir/hashswf.c.o
[3/8] Building C object src/main/cpp/librtmp/CMakeFiles/rtmp.dir/rtmp.c.o
[4/8] Building C object src/main/cpp/librtmp/CMakeFiles/rtmp.dir/amf.c.o
[5/8] Building C object src/main/cpp/librtmp/CMakeFiles/rtmp.dir/parseurl.c.o
...
src/main/cpp/librtmp/CMakeFiles/rtmp.dir/hashswf.c.o   -c F:\Code\Android\VideoLive\app\src\main\cpp\librtmp\hashswf.c
F:\Code\Android\VideoLive\app\src\main\cpp\librtmp\hashswf.c:56:10: fatal error: 'openssl/ssl.h' file not found#include <openssl/ssl.h>^~~~~~~~~~~~~~~1 error generated.

意思是在编译 hashswf.c 文件时,找不到 openssl/ssl.h 文件。实际上是因为我们没有引入 openssl 工具包。openssl 是用来进行数据加密的,加密意味着耗时,由于视频直播对时效性要求高,因此我们暂时不考虑引入 openssl。那如何规避掉编译错误呢?

我们先来看报错的 hashswf.c:

#ifdef CRYPTO...
#include <openssl/ssl.h>
#include <openssl/sha.h>
#include <openssl/hmac.h>
#include <openssl/rc4.h>...
#endif

它只有在定义了 CRYPTO 这个宏的情况下才会导入 openssl,而 CRYPTO 是在 rtmp.h 中定义的:

#if !defined(NO_CRYPTO) && !defined(CRYPTO)
#define CRYPTO
#endif

就是没有定义 NO_CRYPTO 和 CRYPTO 这两个宏时,才会定义 CRYPTO。所以这里才会通过定义 NO_CRYPTO 宏的方式来规避 openssl 的导入。

最后配置 app 模块下的 CMakeLists,将上面的 CMakeLists 嵌套进来:

cmake_minimum_required(VERSION 3.22.1)project("pusher")# 添加 librtmp 目录进来
add_subdirectory(librtmp)# 包含 librtmp 目录,这样导入其文件时就可以不再用""而是用<>
# 使用<>可以避免要导入的文件路径过深而需要写出一长串路径,直接写最终文件名即可
include_directories(librtmp)add_library( pusherSHAREDnative-lib.cpp)find_library( log-liblog)target_link_libraries( pusherrtmp # 添加 RTMP 静态库${log-lib})

2.3 x264 编译与配置

x264 是一个开源的实现了 H.264 协议的视频编码库,提供了 H.264 编码器。它是通过将视频源压缩为 H.264 格式的比特流来实现视频压缩。x264 使用一系列复杂的算法和技术,如运动估计、变换编码、熵编码等,以高效地压缩视频,并提供高质量的图像和视频编码。总的来讲,H.264 是一种视频压缩标准,而 x264 是 H.264 的一个开源实现。

在 VideoLAN 可以下载 x264 的源码,也可以使用 git:

# git clone https://code.videolan.org/videolan/x264.git

接下来使用 NDK 交叉编译 x264 源码,脚本如下:

#!/bin/bash# NDK 根目录
NDK_ROOT=/root/Android/android-ndk-r17c# 编译产物的输出目录
PREFIX=./android/armeabi-v7a# 交叉编译工具所在目录
TOOLCHAIN=$NDK_ROOT/toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64# 编译参数,可以参考 AS 中的 build.ninja 的参数
FLAGS="-isysroot $NDK_ROOT/sysroot -isystem $NDK_ROOT/sysroot/usr/include/arm-linux-androideabi -D__ANDROID_API__=17 -g -DANDROID -ffunction-sections -funwind-tables -fstack-protector-strong -no-canonical-prefixes -march=armv7-a -mfloat-abi=softfp -mfpu=vfpv3-d16 -mthumb -Wa,--noexecstack -Wformat -Werror=format-security  -O0 -fPIC"# 执行脚本的命令,--disable-cli 表示关闭命令行
./configure \
--prefix=$PREFIX \
--disable-cli \
--enable-static \
--enable-pic \
--host=arm-linux \
--cross-prefix=$TOOLCHAIN/bin/arm-linux-androideabi- \
--sysroot=$NDK_ROOT/platforms/android-17/arch-arm \
--extra-cflags="$FLAGS"make clean
make install

在指定的编译产物目录 /android/armeabi-v7a 下会生成两个目录 include 和 lib,分别包含头文件和静态库文件,直接将include 目录拷贝到项目的 src/main/cpp/libx264 下,将 lib 内的静态库文件 libx264.a 拷贝到 src/main/cpp/libx264/libs/armeabi-v7a 下。然后在顶级的 CMakeLIsts.txt 中添加相关配置:

# 添加头文件
include_directories(src/main/cpp/include)# 添加编译库文件,实际上 CMAKE_CXX_FLAGS 这个编译参数会被传到 build.ninja 的 FLAGS 中
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${CMAKE_SOURCE_DIR}/src/main/cpp/libs/${ANDROID_ABI}")target_link_libraries( native-librtmp${log-lib}x264 # 链接到目标库
)

最后在 build.gradle 中配置 CPU 架构过滤参数:

android {defaultConfig {externalNativeBuild {cmake {// 添加这句,这样 CMake 只会编译 armeabi-v7a 架构的库,而不编译 x86 和其他的库// CPU 是哪个架构就只配置那个架构,这样可以避免 APK 打入不使用的库而增大体积abiFilters "armeabi-v7a"}}ndk {// 控制 ndk 只编译 armeabi-v7a 的库,这个也必须配置,否则// 在 System.loadLibrary() 时会因为找不到库而崩溃abiFilters "armeabi-v7a"}}
}

2.4 faac 编译与配置

faac 的 GitHub 主页上可以下载当下最新的 1.30 版本,如果想使用过往版本,可以在 SourceForge 的 faac 主页下载想要的版本。比如下载 1.29 版本:

[root@frank ~]# wget https://zenlayer.dl.sourceforge.net/project/faac/faac-src/faac-1.29/faac-1.29.9.2.tar.gz

解压后编写脚本:

#!/bin/bash
PREFIX=`pwd`/android/armeabi-v7a
NDK_ROOT=/root/AndroidNDK/android-ndk-r17c
TOOLCHAIN=$NDK_ROOT/toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64
CROSS_COMPILE=$TOOLCHAIN/bin/arm-linux-androideabiFLAGS="-isysroot $NDK_ROOT/sysroot -isystem $NDK_ROOT/sysroot/usr/include/arm-linux-androideabi -D__ANDROID_API__=17 -g -DANDROID -ffunction-sections -funwind-tables -fstack-protector-strong -no-canonical-prefixes -march=armv7-a -mfloat-abi=softfp -mfpu=vfpv3-d16 -mthumb -Wa,--noexecstack -Wformat -Werror=format-security -std=c++11  -O0  -fPIC"export CC="$CROSS_COMPILE-gcc --sysroot=$NDK_ROOT/platforms/android-17/arch-arm"
export CFLAGS="$FLAGS"./configure \
--prefix=$PREFIX \
--host=arm-linux \
--with-pic \
--enable-shared=nomake clean
make install

将编译产物中 include 目录下的两个头文件以及 lib 目录下的 libfaac.a 静态库拷贝到 AS 中并配置 CMakeList:

# 添加 faac 头文件
include_directories(libfaac/include)# 添加 faac 静态库文件路径
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${CMAKE_SOURCE_DIR}/libfaac/libs/${CMAKE_ANDROID_ARCH_ABI}")target_link_libraries(pusherrtmp # 添加 RTMP 静态库x264 # 链接 x 264faac # 链接 faac${log-lib})

至此,所有第三方库导入完毕,准备工作完成。

3、实现思路

整体思路如下:

2024-2-27.直播推流端结构图

摄像头采集视频数据进行视频编码封装进 RTMP 包中,最后通过 RTMPDump 的 RTMP_SendPacket() 将视频包发送给服务器,音频也是类似的过程。

从代码分层的角度看上图,信息采集是在上层完成的,编码与推流是在 Native 层完成的:

在这里插入图片描述

按照从上到下的顺序:

  • Activity 通过 LivePusher 控制 VideoChannel 采集视频、AudioChannel 采集音频
  • Channel 采集到每一帧数据后,都调用 LivePusher 的 Native 方法将数据交给 Native 层
  • Native 层的入口 native-lib 将视频帧交给 VideoChannel 进行视频编码,将音频帧交给 AudioChannel 进行音频编码,编码后的数据转换成 RTMPPacket 存入 RTMPPacket 队列中
  • native-lib 负责连接 RTMP 服务器,并从 RTMPPacket 队列中取出 RTMPPacket 发送给 RTMP 服务器完成推流

上层的结构图如下:

2024-1-5.推流思路图

各部分职责:

  • LivePusher 作为推流功能的入口,控制负责视频的 VideoChannel 和负责音频的 AudioChannel,同时还会定义 Native 方法作为与 Native 层交互的入口
  • VideoChannel 控制 CameraHelper 驱动摄像头采集视频图像,将采集到的图像显示在预览界面的同时,还要经由 LivePusher 传递给 Native 层进行编码
  • AudioChannel 使用 AudioRecord 读取麦克风的录音数据,也是经由 LivePusher 调用 Native 方法传给 Native 层编码发送

4、视频预览

采集视频数据传给底层进行编码之前,需要先实现视频预览,效果如下:

在这里插入图片描述

Android 系统提供了 Camera、Camera2 以及封装了 Camera2 的 Jetpack CameraX 来操控摄像头,我们以 Camera 为例,来看 CameraHelper 的实现。

4.1 初始化

初始化代码如下:

class CameraHelper(private var mActivity: Activity,private var mCameraId: Int,private var mHeight: Int,private var mWidth: Int
) : SurfaceHolder.Callback {private lateinit var mSurfaceHolder: SurfaceHolder/*** 我们需要监听 Surface 的变化,比如当 Surface 销毁时停止 Camera* 的预览,当 Surface 大小发生变化时,重启 Camera 的预览*/fun setPreviewDisplay(surfaceHolder: SurfaceHolder) {mSurfaceHolder = surfaceHolder// 添加监听 Surface 变化的回调mSurfaceHolder.addCallback(this)}// SurfaceHolder.Callback startoverride fun surfaceCreated(holder: SurfaceHolder) {// 在 SurfaceView 创建成功后开启预览才有意义,但是因为还有切换前后摄像头// 的操作,切换不会回调本方法,因此将开启预览的逻辑都放到 surfaceChanged() 中// startPreview()}override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {// 除了 SurfaceView 的创建,还会有切换前后摄像头的操作,surfaceChanged()// 在两种情况下都会被回调,因此在这个回调方法中开启/关闭预览stopPreview()startPreview()}override fun surfaceDestroyed(holder: SurfaceHolder) {stopPreview()}// SurfaceHolder.Callback end
}

简单解释一下各项参数:

  • 需要通过 Activity 获取到手机旋转的方向,以便对摄像头采集到的数据做出相应的旋转
  • CameraId 用来指明当前使用前置还是后置摄像头
  • 宽高是用户希望使用的摄像参数,该参数会传给 Camera,但是由于不同厂商的摄像头具有不同的参数规格,因此 Camera 最终使用的宽高参数很可能与传入的不同,只是接近而已
  • 我们使用 SurfaceView 展现预览画面,那么就需要获取 SurfaceHolder,一方面是监听 SurfaceView 尺寸的变化,当发生变化时,需要重新开启预览;另一方面,Camera 提供了 setPreviewDisplay() 可以传入 SurfaceHolder 直接将拍摄到的画面显示在对应的 SurfaceView 上

4.2 开启预览与结束预览

主要操作包括:

  • 根据传入的 CameraId,即前置还是后置摄像头,打开该摄像头获取到 Camera 对象
  • 设置 Camera 参数,包括预览格式、宽高、旋转角度等
  • 设置使用缓冲区进行预览回调,并指定该缓冲区
  • 设置在 SurfaceHolder 持有的 SurfaceView 上进行预览,并开启预览
	// 开启预览fun startPreview() {// 1.打开 CameramCamera = Camera.open(mCameraId)if (mCamera == null) {Log.d(TAG, "Open camera failed.")return}// 2.设置 Camera 参数val cameraParam = mCamera?.parameters// 2.1 设置预览格式为 NV21cameraParam?.previewFormat = ImageFormat.NV21// 2.2 设置预览界面的宽高setPreviewSize(cameraParam)// 2.3 设置预览画面需要旋转的角度和方向setPreviewOrientation(cameraParam)// 2.4 更新 Camera 参数mCamera?.parameters = cameraParam// 3.Camera 数据设置// 3.1 Camera 采集的是 NV21 格式的数据,其占用空间为总像素的 3/2,// mBuffer 用于保存预览数据,mBytes 用于保存推流到服务器上的数据mBuffer = ByteArray(mWidth * mHeight * 3 / 2)mBytes = ByteArray(mBuffer.size)// 3.2 设置预览回调缓冲区,将 Camera 采集的数据存入 mBuffermCamera?.setPreviewCallbackWithBuffer(this)mCamera?.addCallbackBuffer(mBuffer)// 4.开启预览mCamera?.setPreviewDisplay(mSurfaceHolder)mCamera?.startPreview()}// 结束预览private fun stopPreview() {// 设置预览回调为空并停止预览mCamera?.setPreviewCallback(null)mCamera?.stopPreview()// 释放 mCamera 并置为空mCamera?.release()mCamera = null}

该方法内有一些需要解释的内容,在下面几个小节中讲解。

设置预览界面宽高

手机摄像头的宽高参数是有很多规格的,不同的厂商之间规格也都不同。当然,选择不同的宽高参数时,看到的预览画面的尺寸也不同:

严格来说,我们需要通过 setPreviewSize() 设置摄像头的拍摄所使用的参数,并且随之改变预览画面。但是当前我们仅实现设置摄像头参数,预览画面的 SurfaceView 的大小暂时先不动(感兴趣可自行实现)。

在设置摄像头宽高时,由于摄像头可能不支持与传入的宽高一模一样的规格,因此我们要先获取摄像头支持的拍摄规格,再选择与要求的宽高最相近的规格:

	/*** 从摄像头支持的宽高参数中选取与预览界面宽高差值最小的参数,并将其作为预览界面宽高*/private fun setPreviewSize(cameraParam: Camera.Parameters?) {if (cameraParam == null) {return}// 获取摄像头支持的宽高参数val supportedPreviewSizes = cameraParam.supportedPreviewSizesvar selectedSize = supportedPreviewSizes[0]val iterator = supportedPreviewSizes.iterator()var tempValue: Intvar minValue = Integer.MAX_VALUEvar tempSize: Camera.Size// 遍历找到与 mWidth 和 mHeight 最接近的规格while (iterator.hasNext()) {tempSize = iterator.next()tempValue = abs(tempSize.width * tempSize.height - mWidth * mHeight)if (tempValue < minValue) {minValue = tempValueselectedSize = tempSize}}// 将选定的宽高保存到成员变量和 cameraParam 中mWidth = selectedSize.widthmHeight = selectedSize.heightcameraParam.setPreviewSize(mWidth, mHeight)}

设置预览画面的旋转角度

为什么要对预览界面的数据进行旋转?因为 Android 设备的摄像头是横向摆放的:

2024-1-9.Android摄像头横放示意图

如你所见,摄像头是相对于设备顺时针旋转了 90° 放置的,它输出的图像需要顺时针旋转 90° 才与手机摆放的方向相同。所以当手机竖直正向摆放时,你需要将摄像头采集到的像素矩阵顺时针旋转 90° 才能得到正常的视频。参考代码如下:

	// SurfaceView 的宽高发生变化时,需要通知 Native 层重新初始化编码器的interface OnSurfaceSizeChangedListener {fun onSizeChanged(width: Int, height: Int)}private var mOrientation = 0private var mOnSurfaceSizeChangedListener: OnSurfaceSizeChangedListener? = null/*** 根据当前手机的旋转角度调整预览界面的旋转角度,保证预览画面跟随手机的旋转* 而旋转,主要参考 Camera#setDisplayOrientation 注释给出的参考代码*/private fun setPreviewOrientation(cameraParam: Camera.Parameters?) {mOrientation = mActivity.windowManager.defaultDisplay.orientationval degree = when (mOrientation) {Surface.ROTATION_0 -> {mOnSurfaceSizeChangedListener?.onSizeChanged(mHeight, mWidth)0}// 横屏,左边是头部,home 键在右边Surface.ROTATION_90 -> {mOnSurfaceSizeChangedListener?.onSizeChanged(mWidth, mHeight)90}Surface.ROTATION_180 -> {mOnSurfaceSizeChangedListener?.onSizeChanged(mHeight, mWidth)180}// 横屏,头部在右边,home 在左边Surface.ROTATION_270 -> {mOnSurfaceSizeChangedListener?.onSizeChanged(mWidth, mHeight)270}else -> 0}// 获取 CameraInfo 以便后续从中获取前后置摄像头val cameraInfo = Camera.CameraInfo()Camera.getCameraInfo(mCameraId, cameraInfo)// 根据 degree 计算预览界面需要旋转的角度var result: Intif (cameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT) {// 前置摄像头,需要做镜像转换result = (cameraInfo.orientation + degree) % 360result = (360 - result) % 360} else {// 后置摄像头result = (cameraInfo.orientation - degree + 360) % 360}mCamera?.setDisplayOrientation(result)}

当然,以上仅是对预览画面进行了旋转,要传递给 Native 进行编码的数据 mBytes 还没有做旋转处理,我们下一节再说。

mBuffer 与 mBytes

为什么 mBuffer 的大小是 mWidth * mHeight * 3 / 2,这与 YUV 的编码方式有关。先看下面这幅图:

3.2.5.1-RGB与YUV内存对比

YUV 编码中,每个像素点都有一个 Y 分量,UV 分量则是 4 个像素点共用一个,也就是说,在一个 Width * Height 的像素矩阵中,Y 分量的个数就是 Width * Height,而 UV 分量分别为 Width * Height / 4,那么 YUV 分量总计就是 Width * Height * 3 / 2

再来解释 mBuffer 是如何接收到数据的。注意 setPreviewDisplay() 内的这段代码:

	fun setPreviewDisplay(surfaceHolder: SurfaceHolder) {...// 3.2 设置预览回调缓冲区,将 Camera 采集的数据存入 mBuffermCamera?.addCallbackBuffer(mBuffer)mCamera?.setPreviewCallbackWithBuffer(this)...}

首先,addCallbackBuffer() 会将 mBuffer 添加到一个预览回调缓冲队列中,当视频帧到来时,如果队列中有这个 mBuffer,就会把视频帧的数据保存到 mBuffer 中并将其从队列中移除。

其次,CameraHelper 设置了一个预览回调,当摄像头采集到一帧画面时,就通过 Camera.PreviewCallback 接口的 onPreviewFrame() 把数据传给我们:

	interface OnPreviewListener {fun onPreviewFrame(data: ByteArray)}private var mOnPreviewListener: OnPreviewListener? = nulloverride fun onPreviewFrame(data: ByteArray?, camera: Camera?) {if (data == null) {Log.d(TAG, "onPreviewFrame: data 为空,直接返回")return}// 将传给服务器的图像数据旋转 90° 放入 mBytes 中if (mOrientation == Surface.ROTATION_0) {rotate90(data)}// 将页面数据回调给 VideoChannel,再传给 LivePusher 的 native 方法mOnPreviewListener?.onPreviewFrame(mBytes)// 再次将 mBuffer 添加到预览回调缓冲队列中,当有回调数据后就会填入 mBuffermCamera?.addCallbackBuffer(mBuffer)}

在这里,将摄像头采集到的每一帧视频旋转 90° 赋值给 mBytes,再回调给 VideoChannel 传给 Native 层编码发送,至于原因前面已经提过了:

	/*** 对摄像头采集到的数据旋转 90° 后才是调正的图像,* 后置摄像头数据需要顺时针旋转 90°,而前置需要逆时针旋转 90°*/private fun rotate90(data: ByteArray) {var index = 0;val ySize = mWidth * mHeightval uvHeight = mHeight / 2if (mCameraId == Camera.CameraInfo.CAMERA_FACING_BACK) {// 后置,先旋转 y,再旋转 uv,旋转后的数据存入 mBytes 中for (i in 0 until mWidth) {for (j in mHeight - 1 downTo 0) {mBytes[index++] = data[j * mWidth + i]}}// 拷贝 uv,还是 NV21 格式for (i in 0 until mWidth step 2) {for (j in uvHeight - 1 downTo 0) {// vmBytes[index++] = data[ySize + j * mWidth + i]// umBytes[index++] = data[ySize + j * mWidth + i + 1]}}} else {// 前置for (i in 0 until mWidth) {var nPos = mWidth - 1for (j in 0 until mHeight) {mBytes[index++] = data[nPos - i]nPos += mWidth}}// u vfor (i in 0 until mWidth step 2) {var pos = ySize + mWidth - 1for (j in 0 until uvHeight) {mBytes[index++] = data[pos - i - 1]mBytes[index++] = data[pos - i]pos += mWidth}}}}

4.3 前后置摄像头切换

切换 CameraId 再重启预览:

	fun switchCamera() {// 切换摄像头 ID 再重启预览mCameraId = if (mCameraId == Camera.CameraInfo.CAMERA_FACING_BACK) {Camera.CameraInfo.CAMERA_FACING_FRONT} else {Camera.CameraInfo.CAMERA_FACING_BACK}stopPreview()startPreview()}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/318712.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

linux 服务器利用阿里网盘API实现文件的上传和下载

文章目录 背景脚本初始化 阿里云盘API工具 aligo安装aligoaligo教程实战parse.py 演示上传文件上传文件夹下载文件下载文件夹 背景 最近在用ubuntu系统做实验&#xff0c;而ubuntu 系统的文件上传和下载操作很麻烦&#xff1b; 于是便打算使用阿里网盘的API 进行文件下载与上传…

Docker - 修改服务的端口

1. 测试 新建一个httpd服务 docker run -itd -p 1314:80 --name test -h test httpd 2. 先停止容器和 docke r服务 docker stop test #停止容器3. 修改配置 cd /var/lib/docker/containers ls 找到需要修改的 cd 1fc55f0d24014217cff68c9a417ca46cf50312caa5c9e6bb24085126…

为什么 IP 地址通常以 192.168 开头?(精简版)

网络通讯的本质就是收发数据包。如果说收发数据包就跟收发快递一样。IP地址就类似于快递上填的收件地址和发件地址一样&#xff0c;路由器就充当快递员的角色&#xff0c;在这个纷繁复杂的网络世界里找到该由谁来接收这个数据包&#xff0c;所以说&#xff1a;IP地址就像快递里…

django搭建一个AI博客进行YouTube视频自动生成文字博客

文章目录 一、生成Django框架二、项目代码&#xff08;前端&#xff09;1、编写前端代码&#xff08;正文界面&#xff09;1.1、生产html框架1.2、添加live preview扩展1.3、更改title元素中文本1.4、添加CDN&#xff08;CSS&#xff09;样式链接1.5、nav标签1.6、在body标签中…

OpenCV(三)—— 车牌筛选

本篇文章要介绍如何对从候选车牌中选出最终进行字符识别的车牌。 无论是通过 Sobel 还是 HSV 计算出的候选车牌都可能不止一个&#xff0c;需要对它们进行评分&#xff0c;选出最终要进行识别的车牌。这个过程中会用到两个理论知识&#xff1a;支持向量机和 HOG 特征。 1、支…

华为机考入门python3--(19)牛客19- 简单错误记录

分类&#xff1a;字符串 知识点&#xff1a; 分割字符串 my_str.split(\\) 字符串只保留最后16位字符 my_str[-16:] 列表可以作为队列、栈 添加元素到第一个位置 my_list.insert(0, elem) 增加元素到最后一个位置 my_list.append(elem) 删除第一个 my_list.pop(0)…

C/C++开发环境配置

配置C/C开发环境 1.下载和配置MinGW-w64 编译器套件 下载地址&#xff1a;https://sourceforge.net/projects/mingw-w64/files/mingw-w64/mingw-w64-release/ 下载后解压并放至你容易管理的路径下&#xff08;我是将其放在了D盘的一个software的文件中管理&#xff09; 2.…

奈氏准则和香农定理

一、奈奎斯特和香农 哈里奈奎斯特&#xff08;Harry Nyquist&#xff09;(左) 克劳德艾尔伍德香农&#xff08;Claude Elwood Shannon&#xff09;(右) 我们应该在心里记住他们&#xff0c;记住所有为人类伟大事业做出贡献的人&#xff0c;因为他们我们的生活变得越来越精彩&…

【UnityRPG游戏制作】NPC交互逻辑、动玩法

&#x1f468;‍&#x1f4bb;个人主页&#xff1a;元宇宙-秩沅 &#x1f468;‍&#x1f4bb; hallo 欢迎 点赞&#x1f44d; 收藏⭐ 留言&#x1f4dd; 加关注✅! &#x1f468;‍&#x1f4bb; 本文由 秩沅 原创 &#x1f468;‍&#x1f4bb; 收录于专栏&#xff1a;就业…

多级留言/评论的功能实现——SpringBoot3后端篇

目录 功能描述数据库表设计后端接口设计实体类entity 完整实体类dto 封装请求数据dto 封装分页请求数据vo 请求返回数据 Controller控制层Service层接口实现类 Mapper层Mybatis 操作数据库 补充&#xff1a;返回的数据结构自动创建实体类 最近毕设做完了&#xff0c;开始来梳理…

✔ ★Java大项目——用Java模拟RabbitMQ实现一个消息队列(二)【创建核心类、封装数据库操作】

✔ ★Java大项目——用Java模拟RabbitMQ实现一个消息队列 四. 项⽬创建五. 创建核⼼类 ★创建 Exchange&#xff08;名字、类型、持久化、自动删除、参数&#xff09;创建 MSGQueue&#xff08;名字、持久化、独占标识&#xff09;创建 Binding&#xff08;交换机名字、队列名字…

UDP编程流程(UDP客户端、服务器互发消息流程)

一、UDP编程流程 1.1、 UDP概述 UDP&#xff0c;即用户数据报协议&#xff0c;是一种面向无连接的传输层协议。相比于TCP协议&#xff0c;UDP具有以下特点&#xff1a; 速度较快&#xff1a;由于UDP不需要建立连接和进行复杂的握手过程&#xff0c;因此在传输数据时速度稍快…

Arcpy批量克里金插值报错

Arcpy批量克里金插值报错 文章目录 Arcpy批量克里金插值报错问题解决参考 问题 在进行实验的时候&#xff0c;Arcpy中批量进行克里金插值报错&#xff0c;主要就是在运行这个工具的时候&#xff0c;一直报错&#xff0c;改了很多参数也不行 ERROR 010079: 无法估算半变异函数…

MySQL商城数据库88张表结构(46—50)

46、消息队列表 CREATE TABLE dingchengyu消息队列表 (id int(11) NOT NULL AUTO_INCREMENT COMMENT 序号,userId int(11) DEFAULT NULL COMMENT 用户id,msgTtype tinyint(4) DEFAULT 0 COMMENT 消息类型,createTime datetime DEFAULT NULL COMMENT 创建时间,sendTime datetim…

LabVIEW自动剪板机控制系统

LabVIEW自动剪板机控制系统 随着工业自动化的快速发展&#xff0c;钣金加工行业面临着生产效率和加工精度的双重挑战。传统的手动或脚踏式剪板机已无法满足现代生产的高效率和高精度要求&#xff0c;因此&#xff0c;自动剪板机控制系统的研究与开发成为了行业发展的必然趋势。…

【深度学习】序列模型

深度学习&#xff08;Deep Learning&#xff09;是机器学习的一个分支领域&#xff1a;它是从数据中学习表示的一种新方法&#xff0c;强调从连续的层中进行学习&#xff0c;这些层对应于越来越有意义的表示。 1. 为什么选择序列模型&#xff1f; 循环神经网络&#xff08;RNN…

[嵌入式系统-63]:RT-Thread-内核:内核在不同CPU架构上的移植和不同硬件板BSP上的移植

目录 内核移植 1. CPU 架构移植&#xff1a;由CPU厂家提供 1.1 实现全局中断开关&#xff1a;汇编语言实现 &#xff08;1&#xff09;关闭全局中断 &#xff08;2&#xff09;打开全局中断 1.2 实现线程栈初始化 1.3 实现上下文切换 &#xff08;1&#xff09;实现 rt…

零代码编程:用Kimichat从PDF文件中批量提取图片

一个PDF文件中&#xff0c;有很多图片&#xff0c;想批量提取出来&#xff0c;可以借助kimi智能助手。 在借助kimi智能助手中输入提示词&#xff1a; 你是一个Python编程专家&#xff0c;要完成一个网页爬取Python脚本的任务&#xff0c;具体步骤如下&#xff1a; 打开文件夹…

基于深度学习检测恶意流量识别框架(80+特征/99%识别率)

基于深度学习检测恶意流量识别框架 目录 基于深度学习检测恶意流量识别框架简要示例a.检测攻击类别b.模型训练结果输出参数c.前端检测页面d.前端训练界面e.前端审计界面&#xff08;后续更新了&#xff09;f.前端自学习界面&#xff08;自学习模式转换&#xff09;f1.自学习模式…

数据结构与算法之经典排序算法

一、简单排序 在我们的程序中&#xff0c;排序是非常常见的一种需求&#xff0c;提供一些数据元素&#xff0c;把这些数据元素按照一定的规则进行排序。比如查询一些订单按照订单的日期进行排序&#xff0c;再比如查询一些商品&#xff0c;按照商品的价格进行排序等等。所以&a…