FFmpeg for Android & 图传Web
之前在项目研发中有个需求, 需要对接RTSP摄像头, 并且需要将其转封装格式到H5能够播放的格式, 并且需要在纯安卓APP中实现, 并且需要低延迟.
找了一圈都没有合适的且现成的方案, 这个需求有那么小众么? so.. 决定自己动手丰衣足食, 然而整个比想象中复杂一点点..
Android 交叉编译 FFmpeg
FFmpeg 官文 - https://www.ffmpeg.org/documentation.html
请参考之前的笔记博客: FFmpeg 编译 (For Android)
NDK 引用实例
CMakeLists.txt
project("ffmpegInterface")# 使用相对路径
set(FFMPEG_DIR_INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/ffmpeg/include)# set(FFMPEG_DIR_LIB ${CMAKE_CURRENT_SOURCE_DIR}/ffmpeg/lib)
# 若想自动打包到APK , 添加到 jniLibs 目录下
set(FFMPEG_DIR_LIB E:/projects-my/ffmpeg-make/app/src/main/jniLibs)
# 根据 ABI 设置具体路径
if(${ANDROID_ABI} STREQUAL armeabi-v7a)set(FFMPEG_INCLUDE_DIR ${FFMPEG_DIR_INCLUDE}/armeabi-v7a)set(FFMPEG_LIB_DIR ${FFMPEG_DIR_LIB}/armeabi-v7a)
elseif(${ANDROID_ABI} STREQUAL arm64-v8a)set(FFMPEG_INCLUDE_DIR ${FFMPEG_DIR_INCLUDE}/arm64-v8a)set(FFMPEG_LIB_DIR ${FFMPEG_DIR_LIB}/arm64-v8a)
elseif(${ANDROID_ABI} STREQUAL x86)set(FFMPEG_INCLUDE_DIR ${FFMPEG_DIR_INCLUDE}/x86)set(FFMPEG_LIB_DIR ${FFMPEG_DIR_LIB}/x86)
elseif(${ANDROID_ABI} STREQUAL x86_64)set(FFMPEG_INCLUDE_DIR ${FFMPEG_DIR_INCLUDE}/x86_64)set(FFMPEG_LIB_DIR ${FFMPEG_DIR_LIB}/x86_64)
else()message(FATAL_ERROR "Unsupported ABI: ${ANDROID_ABI}")
endif()# 添加头文件搜索路径
include_directories(${FFMPEG_INCLUDE_DIR})# 添加库文件搜索路径
link_directories(${FFMPEG_LIB_DIR})# 生成
add_library(${CMAKE_PROJECT_NAME} SHARED# List C/C++ source files with relative paths to this CMakeLists.txt.ffmpegInterface.cpp)target_link_libraries(${CMAKE_PROJECT_NAME}# List libraries link to the target libraryavcodec avfilter avformat avutil swresample swscaleandroidlog)
CMake 链接不等于打包
即使在 target_link_libraries 中链接了相关库,这只是编译时链接, 运行时仍需要实际的 .so 文件存在于设备中..
自动打包机制:Android Gradle Plugin 会自动将 src/main/jniLibs/ 目录下的原生库文件打包到 APK 中
目录结构需要按照 ABI 架构组织子目录:
app/src/main/jniLibs/
├─arm64-v8a
│ libavcodec.so
│ libavfilter.so
│ libavformat.so
│ libavutil.so
│ libswresample.so
│ libswscale.so
│
└─x86_64libavcodec.solibavdevice.solibavfilter.solibavformat.solibavutil.solibswresample.solibswscale.so
FFmpegInterface.kt
package org.yang.webrtc.ffmpeg.make.ffmpegclass FFmpegInterface {companion object {init {System.loadLibrary("ffmpegInterface")}}external fun ffmpegInit() :String
}
ffmpegInterface.cpp
#include <jni.h>
#include <string>extern "C"
{#include <libavcodec/avcodec.h>#include <libavformat/avformat.h>#include <libavfilter/avfilter.h>#include <libavutil/avutil.h>#include <libswscale/swscale.h>#include <libavutil/log.h>
}//打印 FFMPEG 的支持情况
extern "C"
JNIEXPORT void JNICALL
Java_org_yang_webrtc_ffmpeg_make_ffmpeg_FFmpegInterface_ffmpegInit(JNIEnv *env, jobject thiz) {avformat_network_init();LOGI("FFmpeg version is [%s] ", av_version_info());// 打印FFmpeg版本信息LOGI("===== FFmpeg Version Information =====");LOGI("av_version_info(): %s", av_version_info());LOGI("avcodec_version(): %d.%d.%d", AV_VERSION_MAJOR(avcodec_version()),AV_VERSION_MINOR(avcodec_version()),AV_VERSION_MICRO(avcodec_version()));LOGI("avformat_version(): %d.%d.%d", AV_VERSION_MAJOR(avformat_version()),AV_VERSION_MINOR(avformat_version()),AV_VERSION_MICRO(avformat_version()));LOGI("avutil_version(): %d.%d.%d", AV_VERSION_MAJOR(avutil_version()),AV_VERSION_MINOR(avutil_version()),AV_VERSION_MICRO(avutil_version()));// 打印支持的协议LOGI("\n===== Supported Protocols =====");void* opaque = NULL;const char* name;LOGI("Input Protocols:");while ((name = avio_enum_protocols(&opaque, 0))) {LOGI(" - %s", name);}opaque = NULL;LOGI("\nOutput Protocols:");while ((name = avio_enum_protocols(&opaque, 1))) {LOGI(" - %s", name);}// 打印支持的编码器LOGI("\n===== Supported Encoders =====");const AVCodec *codec = NULL;void *iter = NULL;while ((codec = av_codec_iterate(&iter))) {if (av_codec_is_encoder(codec)) {LOGI("Encoder: %-20s [%s]", codec->name, codec->long_name);}}// 打印支持的解码器LOGI("\n===== Supported Decoders =====");iter = NULL;while ((codec = av_codec_iterate(&iter))) {if (av_codec_is_decoder(codec)) {LOGI("Decoder: %-20s [%s]", codec->name, codec->long_name);}}// 打印支持的封装格式LOGI("\n===== Supported Muxers (Output Formats) =====");const AVOutputFormat *ofmt = NULL;iter = NULL;while ((ofmt = av_muxer_iterate(&iter))) {LOGI("Muxer: %-15s [%s]", ofmt->name, ofmt->long_name);}// 打印支持的解封装格式LOGI("\n===== Supported Demuxers (Input Formats) =====");const AVInputFormat *ifmt = NULL;iter = NULL;while ((ifmt = av_demuxer_iterate(&iter))) {LOGI("Demuxer: %-15s [%s]", ifmt->name, ifmt->long_name);}// 打印支持的硬件加速方法LOGI("\n===== Supported Hardware Acceleration Methods =====");enum AVHWDeviceType type = AV_HWDEVICE_TYPE_NONE;while ((type = av_hwdevice_iterate_types(type)) != AV_HWDEVICE_TYPE_NONE) {LOGI(" - %s", av_hwdevice_get_type_name(type));}// 打印支持的滤镜LOGI("\n===== Supported Filters =====");const AVFilter *filter = NULL;iter = NULL;while ((filter = av_filter_iterate(&iter))) {LOGI("Filter: %-20s [%s]", filter->name, filter->description);}// 打印配置信息LOGI("\n===== Build Configuration =====");LOGI("%s", avcodec_configuration());LOGI("\nFFmpeg initialization complete");
}
/*
extern "C"
JNIEXPORT jstring JNICALL
Java_org_yang_webrtc_ffmpeg_make_ffmpeg_FFmpegInterface_ffmpegInit(JNIEnv *env, jobject thiz) {std::string path = "ffmpeg version is :" + std::string(av_version_info()) ;return env->NewStringUTF(path.c_str() );//返回字符串
}
*/
关于WebRTC
WebRTC 的全称是 Web Real-Time Communication,即网页实时通信。它是一套开放的、允许网页浏览器直接进行实时音视频通话和数据共享的 API 和协议集合。
核心价值:在 WebRTC 出现之前,要想在浏览器里进行视频聊天,必须安装像 Flash、Silverlight 这样的插件。WebRTC 将其标准化,使得开发者仅需使用 JavaScript API 就能实现高质量的实时通信功能,无需任何插件。
关键概念
媒体流 - MediaStream
一个 MediaStream对象代表一个同步的媒体数据流。比如,它可以是:
- 视频轨
- 音频轨
你可以把一个MediaStream理解成一个容器**,里面装了一条或多条“轨道”。
信令 - Signaling
WebRTC 本身是点对点 的,但建立P2P连接所需要的信息交换,WebRTC标准并未规定。这个交换信息的过程就是信令。
信令信道的作用:就像两个陌生人要打电话,他们需要先通过某种方式(比如短信)告诉对方自己的电话号码。这个“发短信”的通道就是信令信道。
信令交换的信息包括:
- 会话描述协议:也就是“我想和你通话,这是我的媒体能力(支持哪些编解码器、分辨率等)”。
- 网络信息:也就是“这是我的网络地址(IP和端口),你可以通过这个地址找到我”。
in short 规范没有定义信令的数据传输方式,通常是使用 WebSocket 或 HTTP 长轮询等技术,通过你自己的服务器在两个浏览器之间传递这些信息。
对点连接 - RTCPeerConnection
负责建立和管理两个浏览器之间的安全、高效、稳定的点对点连接。它处理了所有最复杂的事情:
- NAT 穿透:大多数设备都在路由器后面,没有公网 IP。
RTCPeerConnection使用 ICE 框架(结合 STUN/TURN 服务器)来尝试建立直接的 P2P 连接。 - 编解码处理:自动协商双方都支持的音视频编解码器(如 VP8, VP9, H.264)。
- 网络适应性:根据网络状况(带宽、延迟、丢包)动态调整视频质量、码率等。
- 加密传输:所有传输的数据都是强制加密的。
数据通道 - RTCDataChannel
除了音视频,RTCPeerConnection还允许你建立一个点对点的数据通道,可以用于传输任意数据,比如文字聊天、文件传输、游戏状态同步等。它类似于 WebSocket,但是是 P2P 的,延迟更低。
关键API
| API 方法 | 作用说明 | 格式(参数) |
|---|---|---|
RTCPeerConnection() |
创建一个新的 WebRTC 连接对象。 | new RTCPeerConnection([configuration]) |
createOffer() |
由呼叫方创建一份“提议”,包含本端的媒体和能力信息。 | pc.createOffer().then(offer => { ... }) |
setLocalDescription() |
将创建的“提议”或“应答(answer)”设置为本地的描述。 | pc.setLocalDescription(offer) |
createAnswer() |
由接收方根据对方的“提议”创建一份“应答(answer)”。 | pc.createAnswer().then(answer => { ... }) |
setRemoteDescription() |
将对方发送过来的“提议”或“应答(answer)”设置为远端的描述。 | pc.setRemoteDescription(answer) |
addTrack() |
将本地音视频轨道添加到连接中。 | pc.addTrack(track, stream) |
createDataChannel() |
创建一个用于传输任意数据的通道。 | pc.createDataChannel('channelName') |
一个典型的 1对1 视频通话工作流程
假设用户 A 想和用户 B 进行视频通话。
-
媒体捕获:
- A 和 B 的浏览器都调用
getUserMedia获取本地的摄像头和麦克风媒体流。
- A 和 B 的浏览器都调用
-
创建连接对象:
- A 和 B 各自创建一个
RTCPeerConnection对象。
- A 和 B 各自创建一个
-
**信令交换 -
信令交换流程 纯浏览器:
- 呼叫方 调用
createOffer()生成一个包含其 SDP 的“提议”。 - 通过 信令服务器(如 WebSocket)将这个 SDP 提议发送给接收方。
- 接收方 收到 SDP 提议后,调用
setRemoteDescription(offer)告诉浏览器对方的信息。 - 接收方 调用
createAnswer()生成一个对应的 SDP“应答(answer)”。 - 通过 信令服务器 将这个 SDP 应答(answer)发回给呼叫方。
- 呼叫方 收到 SDP 应答(answer)后,调用
setRemoteDescription(answer)。
// 创建要约 const peerConnection = new RTCPeerConnection(); const offer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(offer);// 查看要约 SDP 内容 console.log('要约 SDP:', offer.sdp);// 发送要约到对方(通过信令服务器) signalingServer.send({type: 'offer',sdp: offer.sdp });// 对方收到要约后 await peerConnection.setRemoteDescription(offer); const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer);// 查看应答 SDP 内容 console.log('应答 SDP:', answer.sdp); - 呼叫方 调用
-
ICE 候选交换:
- 在创建
RTCPeerConnection和设置描述的过程中,A 和 B 会发现自己的网络地址(称为 ICE 候选)。 - 每当发现一个新的候选地址,他们就会通过信令服务器将这个候选地址发送给对方。
- 对方通过
pc.addIceCandidate(candidate)来添加这个网络路径。
- 在创建
-
建立 P2P 连接 & 传输流:
- 当信令和 ICE 候选交换完成后,
RTCPeerConnection会尝试所有可能的网络路径来建立直接连接。 - 连接建立成功后,A 和 B 就可以开始传输音视频数据了。
- A 通过
pc.addTrack(stream.getVideoTracks()[0], stream)将他的视频轨道添加到连接中。 - B 的浏览器会触发
ontrack事件,在事件回调中,B 可以将接收到的 A 的视频流绑定到一个<video>元素上播放。
- 当信令和 ICE 候选交换完成后,
要约 vs 应答
| 特征 | 要约 | 应答 |
|---|---|---|
| DTLS 角色 | a=setup:actpass(可以主动或被动) |
a=setup:active或 a=setup:passive(明确角色) |
| 编解码器列表 | 列出所有支持的编解码器 | 只包含双方都支持的编解码器子集 |
| ICE 凭证 | 生成新的 ice-ufrag 和 ice-pwd | 生成自己的 ice-ufrag 和 ice-pwd |
| 媒体端口 | 可能是实际端口或占位符 | 确认最终使用的端口 |
邀约的格式
信令是 WebRTC 的灵魂。它的目的是让两个浏览器之间交换必要的网络和媒体信息。这些信息被封装在 SDP 中。
SDP 的格式是纯文本的,结构清晰,由多行 <type>=<value>的键值对组成。
一个典型的 Offer SDP 示例:
v=0 // SDP 版本号
o=- 8616478034590271513 2 IN IP4 127.0.0.1 // 会话源标识符
s=- // 会话名称
t=0 0 // 会话有效时间
a=group:BUNDLE 0 1 // 指示音频和视频流复用同一个传输通道
a=msid-semantic: WMS local-stream // 媒体流标识符// 音频媒体流描述
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126
// ^ 媒体类型 ^ 端口(9 代表`discard`端口,实际端口由ICE决定) ^ 传输协议 ^ 支持的编解码器负载类型
c=IN IP4 0.0.0.0 // 连接信息(在ICE中通常无效)
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:khLS // ICE 用户名片段(用于连通性检查)
a=ice-pwd:CTc0134a304DhQZ1e2gjsdQz // ICE 密码
a=fingerprint:sha-256 FA:62:...:35 // DTLS 证书指纹,用于安全连接
a=setup:actpass // DTLS 角色,actpass 表示“我可以是客户端或服务端”
a=mid:0 // 媒体流ID,与BUNDLE对应
a=sendrecv // 媒体流方向:sendrecv(收发)、recvonly(只收)等
a=rtpmap:111 opus/48000/2 // 编解码器映射:负载类型111对应Opus编码
a=rtpmap:103 ISAC/16000
... // 更多编解码器参数// 视频媒体流描述
m=video 9 UDP/TLS/RTP/SAVPF 100 101 107 116 117 96 97 99 98
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:khLS
a=ice-pwd:CTc0134a304DhQZ1e2gjsdQz
a=fingerprint:sha-256 FA:62:...:35
a=setup:actpass
a=mid:1
a=sendrecv
a=rtpmap:100 VP8/90000 // 编解码器映射:负载类型100对应VP8编码
a=rtpmap:101 VP9/90000
...
-
会话级别信息
v=0:SDP 版本号o=:会话起源信息(用户名、会话ID、版本、网络类型、地址类型、地址)s=-:会话名称(通常为"-")
-
媒体描述块 - 每个
m=行开始一个媒体描述m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104...:音频媒体行audio:媒体类型9:端口号(在offer/answer中通常是占位符)UDP/TLS/RTP/SAVPF:传输协议111 103 104...:支持的负载类型(对应下面的编解码器)
-
连接数据
c=IN IP4 0.0.0.0:连接信息(在offer中通常是0.0.0.0)
-
ICE 参数
a=ice-ufrag:khLS:ICE 用户名片段a=ice-pwd:cx5ZZgLSQ3GQl8l+apE+Ys:ICE 密码- 这些用于验证 ICE 候选
-
DTLS 参数
a=fingerprint:sha-256 12:DF:3E...:证书指纹,用于安全连接a=setup:actpass:DTLS 角色协商(actpass 表示可以充当客户端或服务器)
-
媒体能力
a=rtpmap:111 opus/48000/2:编解码器映射(负载类型111对应Opus编码,48kHz,2声道)a=rtpmap:96 VP8/90000:视频编解码器VP8,时钟频率90kHza=sendrecv:媒体方向(发送和接收)
-
编解码器参数
a=fmtp:111 minptime=10;useinbandfec=1:Opus编码器的具体参数
应答的格式
v=0
o=- 4611731400430051337 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1 2
a=extmap-allow-mixed
a=msid-semantic: WMS
m=audio 9 UDP/TLS/RTP/SAVPF 111 103
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:Yf8o
a=ice-pwd:GEY91yL0qLdW3lq6a+5vVz
a=ice-options:trickle
a=fingerprint:sha-256 34:AB:CD:EF:56:78:90:12:34:56:78:90:...
a=setup:active
a=mid:0
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=sendrecv
a=rtcp-mux
a=rtpmap:111 opus/48000/2
a=fmtp:111 minptime=10;useinbandfec=1
a=rtpmap:103 ISAC/16000
m=video 9 UDP/TLS/RTP/SAVPF 96 98
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:Yf8o
a=ice-pwd:GEY91yL0qLdW3lq6a+5vVz
a=ice-options:trickle
a=fingerprint:sha-256 34:AB:CD:EF:56:78:90:12:34:56:78:90:...
a=setup:passive
a=mid:1
a=sendrecv
a=rtcp-mux
a=rtpmap:96 VP8/90000
a=rtcp-fb:96 ccm fir
a=rtcp-fb:96 nack
a=rtpmap:98 VP9/90000
混合开发 RTSP 图传实现
WebRTC 播放
in short 规范没有定义信令的数据传输方式, 建立P2P通信, 关键在于对接交换信令的过程, 定制开发即可!
- A 创建邀约(offer):A 调用
pc.createOffer()创建一个“邀约(offer)”,包含 A 的媒体能力。 - A 设置本地描述:A 调用
pc.setLocalDescription(offer)将这个“邀约(offer)”设为自己的本地描述。 - A 发送邀约(offer):A 通过信令服务器(如 WebSocket)将这个“邀约(offer)”发送给 B。
- B 设置远端描述:B 收到 A 的“邀约(offer)”后,调用
pc.setRemoteDescription(offer),告诉自己的连接对象“A 是这么说的”。 - B 创建应答(answer):B 调用
pc.createAnswer()创建一个“应答(answer)”,包含 B 的媒体能力。 - B 设置本地描述:B 调用
pc.setLocalDescription(answer)。 - B 发送应答(answer):B 通过信令服务器将“应答(answer)”发送给 A。
- A 设置远端描述:A 收到 B 的“应答(answer)”后,调用
pc.setRemoteDescription(answer)。
<!--* @Author: yangfh* @Date: 2025-10-11 10* @LastEditors: yangfh* @LastEditTime: 2025-10-11 10* @Description: *
-->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>WebRTC 测试</title>
</head>
<body>
<h3>WebRTC 测试播放界面</h3>
<video id="video" autoplay playsinline controls></video><script>
let pc = new RTCPeerConnection();pc.ontrack = e => {document.getElementById("video").srcObject = e.streams[0];
};// 由Android传入offer
function onOffer(sdp) {pc.setRemoteDescription({ type: "offer", sdp }).then(() => pc.createAnswer()).then(answer => {pc.setLocalDescription(answer);// 回传 answer 给 Android WebRTCServerif (window.AndroidInterface) {window.AndroidInterface.onAnswer(answer.sdp);}});
}
</script>
</body>
</html>
package org.yang.webrtc.ffmpeg.make.webrtc;import android.content.Context;
import android.util.Log;import org.webrtc.*;import java.nio.ByteBuffer;
import java.util.Collections;public class WebRTCServer {private static final String TAG = "WebRTCServer";private PeerConnectionFactory factory;private PeerConnection peerConnection;private VideoTrack videoTrack;private SurfaceTextureHelper surfaceTextureHelper;private VideoSource videoSource;private EglBase eglBase;public interface SDPListener {void onLocalSDP(String sdp);}public WebRTCServer(Context context) {eglBase = EglBase.create();PeerConnectionFactory.initialize(PeerConnectionFactory.InitializationOptions.builder(context).setEnableInternalTracer(true).createInitializationOptions());PeerConnectionFactory.Options options = new PeerConnectionFactory.Options();factory = PeerConnectionFactory.builder().setOptions(options).setVideoEncoderFactory(new DefaultVideoEncoderFactory(eglBase.getEglBaseContext(), true, true)).setVideoDecoderFactory(new DefaultVideoDecoderFactory(eglBase.getEglBaseContext())).createPeerConnectionFactory();videoSource = factory.createVideoSource(false);videoTrack = factory.createVideoTrack("ARDAMSv0", videoSource);videoTrack.setEnabled(true);}public void createPeer(SDPListener listener) {PeerConnection.RTCConfiguration rtcConfig =new PeerConnection.RTCConfiguration(Collections.emptyList());rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;peerConnection = factory.createPeerConnection(rtcConfig, new PeerConnection.Observer() {@Override public void onIceCandidate(IceCandidate candidate) {}@Overridepublic void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {}@Override public void onAddStream(MediaStream stream) {}@Overridepublic void onRemoveStream(MediaStream mediaStream) {}@Overridepublic void onDataChannel(DataChannel dataChannel) {}@Overridepublic void onRenegotiationNeeded() {}@Overridepublic void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {}@Overridepublic void onSignalingChange(PeerConnection.SignalingState signalingState) {}@Overridepublic void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {}@Override public void onConnectionChange(PeerConnection.PeerConnectionState newState) {Log.i(TAG, "Connection: " + newState);}@Overridepublic void onIceConnectionReceivingChange(boolean b) {}@Overridepublic void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) {}@Override public void onTrack(RtpTransceiver transceiver) {}});peerConnection.addTrack(videoTrack);peerConnection.createOffer(new SdpObserver() {@Override public void onCreateSuccess(SessionDescription sdp) {peerConnection.setLocalDescription(this, sdp);listener.onLocalSDP(sdp.description);}@Override public void onSetSuccess() {}@Override public void onCreateFailure(String s) { Log.e(TAG, s); }@Override public void onSetFailure(String s) {}}, new MediaConstraints());}public void setRemoteSDP(String sdp) {SessionDescription remote = new SessionDescription(SessionDescription.Type.ANSWER, sdp);peerConnection.setRemoteDescription(new SdpObserver() {@Override public void onSetSuccess() { Log.i(TAG, "Remote SDP set"); }@Override public void onSetFailure(String s) { Log.e(TAG, s); }@Override public void onCreateSuccess(SessionDescription sessionDescription) {}@Override public void onCreateFailure(String s) {}}, remote);}/***// TODO 待实现, 貌似使用MSE api 更加简单// * 从 FFmpeg 拿到的是 压缩 H.264 NALU。// * WebRTC 的 VideoSource 接口只接受原始帧(I420 或 NV12)// * 模拟VideoFrame推送接口// 推送H264帧到WebRTC视频流* @param data* @param timestampMs*/public void pushH264Frame(byte[] data, long timestampMs) {// ByteBuffer buffer = ByteBuffer.wrap(data);
// VideoFrame.I420Buffer dummyBuffer = JavaI420Buffer.allocate(1, 1);
// VideoFrame frame = new VideoFrame(dummyBuffer, 0, timestampMs * 1000);
// videoSource.getCapturerObserver().onFrameCaptured(frame);
// frame.release();}public void release() {if (peerConnection != null) peerConnection.close();if (factory != null) factory.dispose();if (eglBase != null) eglBase.release();}
}
fun switchSDP(){val ffmpeg = FFmpegInterface()ffmpeg.ffmpegInit();val webrtcServer = WebRTCServer(this)webrtcServer.createPeer { sdp: String? ->Log.i("switchSDP", "onCreatePeer SDP 的内容是: $sdp")mHandler.post{// SDP 发给 WebViewwebView.evaluateJavascript("onOffer(" + JSONObject.quote(sdp) + ")", null)}}ffmpeg.startRtspPullById("cam01", "rtsp://admin:sny123.com@192.168.20.80:554/h264/ch1/main/av_stream" ) { data: ByteArray?, pts: Int, dts: Int ->webrtcServer.pushH264Frame(data,pts.toLong())}}
从 FFmpeg 拿到的是 压缩的 H.264 NALU。
原始 H.264 NALU 数据不能直接通过 WebRTC 的 RTCDataChannel或直接作为媒体流发送。它们必须被封装成符合 RFC 6184 规范的 RTP 包。
将 Annex B 格式的 NALU 数据按照 RFC 6184 规则封装成 RTP 包。包括:
- 添加 RTP 头部(版本、填充位、扩展位、CSRC计数、标记位、序列号、时间戳、SSRC)。
- 根据 NALU 大小决定使用 Single NALU Mode 还是 FU-A Mode。
- 构造 FU-A 分片头或 STAP-A 聚合头。
- 处理 SPS/PPS
贼麻烦 也可以使用MSE:
MSE 播放
使用MSE:
- Android NDK 用 FFmpeg 拉流(RTSP → H264 NALU)
- 通过 WebSocket 推送 H264 数据片段(fMP4 格式)
- Web 端用 Media Source Extensions (MSE) 播放
前端 MSE
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="description" content="jMuxer - a simple javascript mp4 muxer for non-standard streaming communications protocol"><meta name="keywords" content="h264 player, mp4 player, mse, mp4 muxing, jmuxer, aac player"><title> stream demo</title></head>
<body>
<div id="container" style="margin: 0 auto; text-align: center;"> <div style="padding: 5px;"><button id="init_but" onclick="init()">init</button><button id="player_but" onclick="player()">player</button><button onclick="fb()">-1S</button><button onclick="fs()">+1S</button><button onclick="debugg()">last</button></div><video style="border: 1px solid #333; max-width: 600px;height: 400px;" controls autoplay id="player" muted></video></div>
<script>var chunks = [];
var video = document.getElementById('player');
function init() {console.log("=============== init ===============");document.getElementById('init_but').disabled = true;window.mse = new (MediaSource || WebKitMediaSource)();window.sourceBuffer;video.src = URL.createObjectURL(window.mse);video.autoplay = true;video.playsInline = true;video.muted = true;mse.addEventListener('sourceopen', onMediaSourceOpen);
}function onMediaSourceOpen() {sourceBuffer = mse.addSourceBuffer('video/mp4; codecs="avc1.42E01E, mp4a.40.2"');sourceBuffer.timestampOffset = 0;sourceBuffer.appendWindowStart = 0;sourceBuffer.appendWindowEnd = Infinity;sourceBuffer.addEventListener('updateend', addMoreBuffer);video.play();
}
function addMoreBuffer() {if (sourceBuffer.updating || !chunks.length) {return;}sourceBuffer.appendBuffer(chunks.shift());
}window.onload = function() {init();player();
};function player(){var socketURL = 'ws://127.0.0.1:19909';//var socketURL = 'ws://localhost:3265/open/api/we';var ws = new WebSocket(socketURL);ws.binaryType = 'arraybuffer';ws.addEventListener('message',function(event) {chunks.push(new Uint8Array(event.data));addMoreBuffer();});ws.addEventListener('error', function(e) {console.log('Socket Error');});document.getElementById('player_but').disabled = true;}
function fs(){video.currentTime = video.currentTime+1
}
function fb(){video.currentTime = video.currentTime-1
}
function debugg(){console.log(video)video.currentTime = sourceBuffer.buffered.end(0)
}
</script>
</body>
</html>
低延迟 fmp4
两个地方
一个是 打开 RTSP 输入时
// --- 打开 RTSP ---av_dict_set(&opts, "rtsp_transport", "tcp", 0);av_dict_set(&opts, "fflags", "nobuffer+flush_packets", 0);av_dict_set(&opts, "max_delay", "100000", 0);//微秒 0.1sav_dict_set(&opts, "flags", "low_delay", 0);
// av_dict_set(&opts, "stimeout", "500000", 0); // 0.5sif (avformat_open_input(&in_fmt, ctx->url.c_str(), nullptr, &opts) < 0) {LOGE("Failed to open RTSP: %s", ctx->url.c_str());cleanup();return;}
一个是 设置 out 的 AVFormatContext 时
// --- 设置 fMP4 分片输出参数 ---av_dict_set(&muxOpts, "movflags", "frag_keyframe+empty_moov+default_base_moof+dash", 0);av_dict_set(&muxOpts, "flush_packets", "1", 0);av_dict_set(&muxOpts, "min_frag_duration", "0", 0);av_dict_set(&muxOpts, "frag_duration", "200000", 0);//分片时长微秒, 这个太低会转流时崩溃!....// --- 写头部(发送 init segment)---if (avformat_write_header(out_fmt, &muxOpts) < 0) {LOGE("avformat_write_header failed");cleanup();return;}while (ctx->running) {int ret = av_read_frame(in_fmt, pkt);if (ret < 0) {break;}..........pkt->stream_index = out_stream->index;if (read_frame_count< 100 ) {LOGI("[pkt %ld] stream=%s,地址:0x%" PRIxPTR ", pts=%" PRId64 ", dts=%" PRId64 ", size=%d, key=%d, dur=%" PRId64 ", pos=%" PRId64,push_frame_count,pkt->stream_index==videoIndex?"video":"audio",(uintptr_t)pkt,pkt->pts,pkt->dts,pkt->size,(pkt->flags & AV_PKT_FLAG_KEY) ? 1 : 0,pkt->duration,pkt->pos);}
// ret = av_interleaved_write_frame(out_fmt, pkt);ret = av_write_frame(out_fmt, pkt);//转封装格式av_packet_unref(pkt);}....
Fatal signal 11 (SIGSEGV) crash?
A Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0xb07bebdc15b034 in tid 9650 (RenderThread), pid 9631 (rtc.ffmpeg.make)
document.getElementById('init_but').disabled = true;
window.mse = new (MediaSource || WebKitMediaSource)();
window.sourceBuffer;
video.src = URL.createObjectURL(window.mse);//加载渲染
video.autoplay = true;
video.playsInline = true;
video.muted = true;
FFmpeg 转封格式 av_write_frame(out_fmt, pkt); 叠加前端 video.src = URL.createObjectURL(window.mse);//视频渲染 会导致奇妙的化学反应 渲染线程指针越界? app崩溃, 系统级问题. 离大谱, 遂换个方式解决 直接找个能播放裸流的播放器..
EasyPlayer 播放(裸流)
EasyPlayer - https://github.com/EasyDarwin/EasyPlayer.js
使用 EasyPlayer 可以配置播放H264裸流, 无需ffmpeg转封装格式!!
EasyPlayer
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>演示</title><script src="./js/EasyPlayer-pro.js"></script>
</head>
<style>.player_item {position: relative;padding-bottom: 56%;background-color: black;margin-bottom: 20px;}.player_box {position: absolute;top: 0;bottom: 0;right: 0;left: 0;}.btn-item {cursor: pointer;display: inline-block;padding: 6px 12px;margin-right: 15px;border-radius: 4px;border: 1px #ccc solid;}.df {display: flex;align-items: center;margin-bottom: 16px;}.box {margin: auto;max-width: 800px;}
</style><body><div class="box"><div class="player_item"><div class="player_box" id="player_box"></div></div><div><!-- <input id="hasAudio" type="checkbox" /><span >音频</span> --></div><input class="inputs" type="text" id="input" value="ws://127.0.0.1:19909"><div><div class="btn-item" id="onPlayer" >播放</div><div class="btn-item" id="onReplay" >重播</div><div class="btn-item" id="onMute">静音</div><div class="btn-item" id="onStop" >注销</div></div></div><script>window.onload = function () {var playerInfo = nullvar config = {isLive:true,hasAudio:false,isMute:false,MSE:false,//MSE 不稳定, 卡画面isFlow:true,loadTimeOut:10,bufferTime:-1,webGPU:false,//webGPU 不稳定, 卡画面canvasRender:true,gpuDecoder:true,stretch: false}playCreate()var input = document.getElementById('input');var player = document.getElementById('onPlayer');var replay = document.getElementById('onReplay');var mute = document.getElementById('onMute');var stop = document.getElementById('onStop');player.onclick = () => {onPlayer()}replay.onclick = () => {onReplay()}mute.onclick = () => {onMute()}stop.onclick = () => {onStop()}function playCreate() {var container = document.getElementById('player_box');playerInfo = new EasyPlayerPro(container, config);}function onPlayer() {playerInfo && playerInfo.play(input.value).then(() => {}).catch((e) => {console.error(e);});}function onMute() {if (!playerInfo)returnplayerInfo.setMute(true)}function onReplay() {onDestroy().then(() => {playCreate();onPlayer()});}function onDestroy() {return new Promise((resolve, reject) => {if (playerInfo) {playerInfo.destroy()playerInfo = null}setTimeout(() => {resolve();}, 100);})}function onStop() {onDestroy().then(() => {playCreate();});}//自动播放setTimeout(() => { onPlayer() }, 1500);}</script>
</body></html>
FFmpeg 转流
#include <jni.h>
#include <string>
#include <thread>
#include <map>
#include <mutex>
#include <atomic>
#include <android/log.h>extern "C" {#include <libavformat/avformat.h>#include <libavcodec/avcodec.h>#include <libavutil/avutil.h>#include <libavutil/time.h>
}#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "RTSP_FFMPEG", __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "RTSP_FFMPEG", __VA_ARGS__)static JavaVM *g_vm = nullptr;struct StreamContext {std::string id;std::string url;std::thread thread;std::atomic<bool> running{false};jobject wsServer; // WebSocket 推送服务
};static std::map<std::string, StreamContext*> g_streams;
static std::mutex g_mutex;static void rtspThread(StreamContext *ctx);// 输出到文件 调试用
static FILE* init_file(const char* output_filename) {// 打开输出文件FILE* output_file = fopen(output_filename, "ab"); // 追加二进制模式if (!output_file) {LOGE("Could not open output file");return nullptr;}return output_file;
}extern "C"
JNIEXPORT void JNICALL
Java_org_yang_webrtc_ffmpeg_make_ffmpeg_FFmpegInterface_startRtspToWs(JNIEnv *env, jobject thiz,jstring jid, jstring jurl, jobject ws_server) {const char *id = env->GetStringUTFChars(jid, nullptr);const char *url = env->GetStringUTFChars(jurl, nullptr);std::lock_guard<std::mutex> lock(g_mutex);if (g_streams.find(id) != g_streams.end()) {LOGI("Stream [%s] already exists", id);env->ReleaseStringUTFChars(jid, id);env->ReleaseStringUTFChars(jurl, url);return;}LOGI("FFMPEG VERSION [%s] ", av_version_info());StreamContext *ctx = new StreamContext();ctx->id = id;ctx->url = url;ctx->running = true;env->GetJavaVM(&g_vm);ctx->wsServer = env->NewGlobalRef(ws_server);g_streams[id] = ctx;ctx->thread = std::thread(rtspThread, ctx);LOGI("Start RTSP pull id=%s, url=%s", id, url);env->ReleaseStringUTFChars(jid, id);env->ReleaseStringUTFChars(jurl, url);
}static void rtspThread(StreamContext *ctx) {JNIEnv *env = nullptr;g_vm->AttachCurrentThread(&env, nullptr);jclass wsCls = env->GetObjectClass(ctx->wsServer);jmethodID broadcastFrame = env->GetMethodID(wsCls, "broadcastFrame", "([B)V");
// jmethodID broadcastFrame = env->GetMethodID(wsCls, "broadcastFrame", "(Ljava/nio/ByteBuffer;)V");avformat_network_init();AVFormatContext *fmt_ctx = nullptr;AVDictionary *opts = nullptr;av_dict_set(&opts, "rtsp_transport", "tcp", 0);av_dict_set(&opts, "stimeout", "5000000", 0);
// av_dict_set(&opts, "rtsp_transport", "tcp", 0);
// av_dict_set(&opts, "fflags", "nobuffer+flush_packets", 0);
// av_dict_set(&opts, "max_delay", "100000", 0);//微秒 0.1s
// av_dict_set(&opts, "flags", "low_delay", 0);int ret = avformat_open_input(&fmt_ctx, ctx->url.c_str(), nullptr, &opts);av_dict_free(&opts);if (ret < 0 || !fmt_ctx) {LOGE("Failed to open RTSP: %s", ctx->url.c_str());env->DeleteGlobalRef(ctx->wsServer);g_vm->DetachCurrentThread();return;}if (avformat_find_stream_info(fmt_ctx, nullptr) < 0) {LOGE("Failed to find stream info");avformat_close_input(&fmt_ctx);env->DeleteGlobalRef(ctx->wsServer);g_vm->DetachCurrentThread();return;}int videoIndex = -1;for (unsigned int i = 0; i < fmt_ctx->nb_streams; ++i) {if (fmt_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {videoIndex = i;break;}}if (videoIndex == -1) {LOGE("No video stream found");avformat_close_input(&fmt_ctx);env->DeleteGlobalRef(ctx->wsServer);g_vm->DetachCurrentThread();return;}AVPacket *pkt = av_packet_alloc();if (!pkt) {LOGE("Failed to allocate packet");avformat_close_input(&fmt_ctx);env->DeleteGlobalRef(ctx->wsServer);g_vm->DetachCurrentThread();return;}LOGI("Start reading RTSP stream [%s]", ctx->id.c_str());//调试输出
// char debug_filename[] = "/sdcard/Download/stream_video_a001.264";
// FILE* output_file = init_file(debug_filename);long read_frame_count = 0;long pust_frame_count = 0;while (ctx->running) {ret = av_read_frame(fmt_ctx, pkt);if (ret < 0) {LOGE("Read frame error or stream ended");break;}read_frame_count++;if (pkt->stream_index == videoIndex && pkt->size > 0) {jbyteArray arr = env->NewByteArray(pkt->size);if (arr != nullptr) {long startTime = av_gettime();env->SetByteArrayRegion(arr, 0, pkt->size, reinterpret_cast<const jbyte*>(pkt->data));env->CallVoidMethod(ctx->wsServer, broadcastFrame, arr);env->DeleteLocalRef(arr);pust_frame_count++;//LOGI("推送 %ld 帧, 调用耗时 %ld ", pust_frame_count, av_gettime() - startTime);}if (read_frame_count< 100 ) {LOGI("[pkt %ld] stream=%s,地址:0x%" PRIxPTR ", pts=%" PRId64 ", dts=%" PRId64 ", size=%d, key=%d, dur=%" PRId64 ", pos=%" PRId64,read_frame_count,pkt->stream_index==videoIndex?"video":"audio",(uintptr_t)pkt,pkt->pts,pkt->dts,pkt->size,(pkt->flags & AV_PKT_FLAG_KEY) ? 1 : 0,pkt->duration,pkt->pos);}if (pkt->stream_index==videoIndex &&(pkt->flags & AV_PKT_FLAG_KEY) ) {LOGI("[%s] Keyframe [%ld] ", ctx->id.c_str(), pust_frame_count);}
// //调试输出
// size_t written = fwrite(pkt->data, 1, pkt->size, output_file);
// if (written != static_cast<size_t>(pkt->size)) {
// LOGE("Write error: wrote %zu of %d bytes", written, pkt->size);
// }}if (read_frame_count % 50 == 0){LOGI("已接受 %ld 帧, 已推送 %ld 帧, size %d", read_frame_count, pust_frame_count, pkt->size);}av_packet_unref(pkt);}LOGI("RTSP thread [%s] stopping", ctx->id.c_str());av_packet_free(&pkt);avformat_close_input(&fmt_ctx);env->DeleteGlobalRef(ctx->wsServer);g_vm->DetachCurrentThread();
}extern "C"
JNIEXPORT void JNICALL
Java_org_yang_webrtc_ffmpeg_make_ffmpeg_FFmpegInterface_stopRtspToWs(JNIEnv *env, jobject, jstring jid) {const char *id = env->GetStringUTFChars(jid, nullptr);std::lock_guard<std::mutex> lock(g_mutex);auto it = g_streams.find(id);if (it == g_streams.end()) {LOGE("Stop: Stream [%s] not found", id);env->ReleaseStringUTFChars(jid, id);return;}StreamContext *ctx = it->second;ctx->running = false;if (ctx->thread.joinable()) ctx->thread.join();delete ctx;g_streams.erase(it);LOGI("Stopped stream [%s]", id);env->ReleaseStringUTFChars(jid, id);
}
注意一点, 推给前端的数据, 若没有开始码, 可以插入NALU的开始码.
//会被 native 调用 推送帧public void broadcastFrame(byte[] data) {synchronized (this){if (receiveCount%100 == 0)Log.i(TAG, "broadcastFrame: thread= "+ Thread.currentThread().getId()+", data size=" + data.length+", clients=" + clients.size()+", receiveCount: " +receiveCount);receiveCount++;for (Client c : clients) {try {byte[]startCode = {0x00,0x00,0x00,0x01};byte[] newData = new byte[data.length+4];System.arraycopy(startCode,0,newData,0,4);System.arraycopy(data,0,newData,4,data.length);c.send(newData);} catch (Exception e) {Log.e(TAG, "Send failed: " + e.getMessage());}}if (receiveCount% 300== 0)Log.i(TAG, "receiveCount: " +receiveCount);}}