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 进行视频通话。

  1. ​媒体捕获​​:

    • A 和 B 的浏览器都调用 getUserMedia获取本地的摄像头和麦克风媒体流。
  2. ​创建连接对象​​:

    • A 和 B 各自创建一个 RTCPeerConnection对象。
  3. ​**​信令交换 -

    信令交换流程​ 纯浏览器:

    1. ​呼叫方​​ 调用 createOffer()生成一个包含其 SDP 的“提议”。
    2. 通过 ​​信令服务器​​(如 WebSocket)将这个 SDP 提议发送给​​接收方​​。
    3. ​接收方​​ 收到 SDP 提议后,调用 setRemoteDescription(offer)告诉浏览器对方的信息。
    4. ​接收方​​ 调用 createAnswer()生成一个对应的 SDP“应答(answer)”。
    5. 通过 ​​信令服务器​​ 将这个 SDP 应答(answer)发回给​​呼叫方​​。
    6. ​呼叫方​​ 收到 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);
    
  4. ​ICE 候选交换​​:

    • 在创建 RTCPeerConnection和设置描述的过程中,A 和 B 会发现自己的网络地址(称为 ​​ICE 候选​​)。
    • 每当发现一个新的候选地址,他们就会通过​​信令服务器​​将这个候选地址发送给对方。
    • 对方通过 pc.addIceCandidate(candidate)来添加这个网络路径。
  5. ​建立 P2P 连接 & 传输流​​:

    • 当信令和 ICE 候选交换完成后,RTCPeerConnection会尝试所有可能的网络路径来建立直接连接。
    • 连接建立成功后,A 和 B 就可以开始传输音视频数据了。
    • A 通过 pc.addTrack(stream.getVideoTracks()[0], stream)将他的视频轨道添加到连接中。
    • B 的浏览器会触发 ontrack事件,在事件回调中,B 可以将接收到的 A 的视频流绑定到一个 <video>元素上播放。

要约 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
...
  1. ​会话级别信息​

    • v=0:SDP 版本号
    • o=:会话起源信息(用户名、会话ID、版本、网络类型、地址类型、地址)
    • s=-:会话名称(通常为"-")
  2. ​媒体描述块​​ - 每个 m=行开始一个媒体描述

    • m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104...:音频媒体行
      • audio:媒体类型
      • 9:端口号(在offer/answer中通常是占位符)
      • UDP/TLS/RTP/SAVPF:传输协议
      • 111 103 104...:支持的负载类型(对应下面的编解码器)
  3. ​连接数据​

    • c=IN IP4 0.0.0.0:连接信息(在offer中通常是0.0.0.0)
  4. ​ICE 参数​

    • a=ice-ufrag:khLS:ICE 用户名片段
    • a=ice-pwd:cx5ZZgLSQ3GQl8l+apE+Ys:ICE 密码
    • 这些用于验证 ICE 候选
  5. ​DTLS 参数​

    • a=fingerprint:sha-256 12:DF:3E...:证书指纹,用于安全连接
    • a=setup:actpass:DTLS 角色协商(actpass 表示可以充当客户端或服务器)
  6. ​媒体能力​

    • a=rtpmap:111 opus/48000/2:编解码器映射(负载类型111对应Opus编码,48kHz,2声道)
    • a=rtpmap:96 VP8/90000:视频编解码器VP8,时钟频率90kHz
    • a=sendrecv:媒体方向(发送和接收)
  7. ​编解码器参数​

    • 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);
        }
    }
posted @ 2025-11-12 20:12  daidaidaiyu  阅读(12)  评论(0)    收藏  举报