FFmpeg for Android & 图传Web
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 library
avcodec avfilter avformat avutil swresample swscale
android
log)
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_64
libavcodec.so
libavdevice.so
libavfilter.so
libavformat.so
libavutil.so
libswresample.so
libswscale.so
FFmpegInterface.kt
package org.yang.webrtc.ffmpeg.make.ffmpeg
class 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 WebRTCServer
if (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) {}
@Override
public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {
}
@Override public void onAddStream(MediaStream stream) {}
@Override
public void onRemoveStream(MediaStream mediaStream) {
}
@Override
public void onDataChannel(DataChannel dataChannel) {
}
@Override
public void onRenegotiationNeeded() {
}
@Override
public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {
}
@Override
public void onSignalingChange(PeerConnection.SignalingState signalingState) {
}
@Override
public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {
}
@Override public void onConnectionChange(PeerConnection.PeerConnectionState newState) {
Log.i(TAG, "Connection: " + newState);
}
@Override
public void onIceConnectionReceivingChange(boolean b) {
}
@Override
public 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 发给 WebView
webView.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.1s
av_dict_set(&opts, "flags", "low_delay", 0);
// av_dict_set(&opts, "stimeout", "500000", 0); // 0.5s
if (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 = null
var 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)return
playerInfo.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);
}
}

浙公网安备 33010602011771号