FFmpeg学习(八)RTMP与FLV协议

一:RTMP协议 

详细解析见:https://www.jianshu.com/p/b2144f9bbe28

(一)RTMP创建流的基本流程

RTMP协议是应用层协议,是要靠底层可靠的传输层协议(通常是TCP)来保证信息传输的可靠性的。
在基于传输层协议的连接建立完成后RTMP协议也要客户端和服务器通过“握手”来建立基于传输层链接之上的RTMP Connection链接
在Connection链接上会传输一些控制信息
,如SetChunkSize,SetACKWindowSize。
其中CreateStream命令会创建一个Stream链接用于传输具体的音视频数据和控制这些信息传输的命令信息。
RTMP协议传输时会对数据做自己的格式化,这种格式的消息我们称之为RTMP Message
而实际传输的时候为了更好地实现多路复用、分包和信息的公平性,发送端会把Message划分为带有Message ID的Chunk
每个Chunk可能是一个单独的Message,也可能是Message的一部分
在接受端会根据chunk中包含的data的长度,message id和message的长度把chunk还原成完整的Message,从而实现信息的收发。

(二)RTMP协议的握手:https://blog.csdn.net/m0_37599645/article/details/116033040(字段含义)

要建立一个有效的RTMP Connection链接,首先要“握手”:
  客户端要向服务器发送C0,C1,C2(按序)三个chunk,
  服务器向客户端发送S0,S1,S2(按序)三个chunk,然后才能进行有效的信息传输。

RTMP协议本身并没有规定这6个Message的具体传输顺序,但RTMP协议的实现者需要保证这几点:

客户端要等收到S1之后才能发送C2
客户端要等收到S2之后才能发送其他信息(控制信息和真实音视频等数据)
服务端要等到收到C0之后发送S1
服务端必须等到收到C1之后才能发送S2
服务端必须等到收到C2之后才能发送其他信息(控制信息和真实音视频等数据)

实际实现中为了在保证握手的身份验证功能的基础上尽量减少通信的次数,一般的发送顺序是如上图中的“RTMP真实的握手”,这一点可以通过wireshark抓ffmpeg推流包进行验证!

(三)建立RTMP连接

    1、客户端发送命令“connect”给服务器
    2、服务器接收到“connect”命令之后,发送消息“确认窗口大小(Window Acknowledgement Size)”给客户端,同时连接到“connect”命令中提到的应用程序
    3、服务器发送消息“设置带宽”给客户端
    4、客户端接收到消息“设置带宽”之后,发送消息“确认窗口大小”给服务器
    5、服务器发送消息“流开始”给客户端
    6、服务器发送消息“结果”给客户端,通知客户端连接的状态

(四)RTMP流中的创建

    1、客户端发送命令“创建流”给服务器
    2、服务器接收到命令之后,发送“结果”给客户端。

在实际中,在创建之前,如果存在该流,那么服务端需要进行释放。然后接着客户端会发送FCPublishStream消息给服务端。

(五)推RTMP流

1.客户端发送publish命令给服务端
2.服务端回复响应消息给客户端
3.客户端发送metaData数据(后面要发送的音、视频基本信息,如分辨率、帧率、采样率...)给服务端
4.客户端发送音视频数据给服务端

其中服务端可以根据元信息,对音视频数据进行编解码,或者直接转发给订阅者。

(六)拉RTMP流

    1、客户端发送命令“播放”给服务器
    2、服务器接收到命令之后,发送消息“设置块大小”给客户端
    3、服务器发送“stream begin”给客户端,告诉客户端 流的id
    4、播放命令成功的话,服务器发送“响应状态”给客户端,告诉客户端播放成功
    5、服务器发送音视频数据给客户端

(七)RTMP消息格式

Chunk Stream是对传输RTMP Chunk的流的逻辑上的抽象,客户端和服务器之间有关RTMP的信息都在这个流上通信。这个流上的操作也是我们关注RTMP协议的重点。

1.Message消息

这里的Message是指满足该协议格式的、可以切分成Chunk发送的消息,消息包含的字段如下:

  • Timestamp(时间戳):消息的时间戳(但不一定是当前时间,后面会介绍),3个字节
  • Length(长度):是指Message Payload(消息负载)即音视频等信息的数据的长度,3个字节
  • TypeId(类型Id):消息的类型Id,1个字节
  • Message Stream ID(消息的流ID):每个消息的唯一标识,划分成Chunk和还原Chunk为Message的时候都是根据这个ID来辨识是否是同一个消息的Chunk的,4个字节,并且以小端格式存储

2.Chunking(Message分块)

RTMP在收发数据的时候并不是以Message为单位的,而是把Message拆分成Chunk发送,而且必须在一个Chunk发送完成之后才能开始发送下一个Chunk。
每个Chunk中带有MessageID代表属于哪个Message,接受端也会按照这个id来将chunk组装成Message。
为什么RTMP要将Message拆分成不同的Chunk呢?
通过拆分,数据量较大的Message可以被拆分成较小的“Message”,这样就可以避免优先级低的消息持续发送阻塞优先级高的数据
比如在视频的传输过程中,会包括视频帧,音频帧和RTMP控制信息,如果持续发送音频数据或者控制数据的话可能就会造成视频帧的阻塞,然后就会造成看视频时最烦人的卡顿现象。
同时对于数据量较小的Message,可以通过对Chunk Header的字段来压缩信息,从而减少信息的传输量。
Chunk的默认大小是128字节,在传输过程中,通过一个叫做Set Chunk Size的控制信息可以设置Chunk数据量的最大值,
在发送端和接受端会各自维护一个Chunk Size,可以分别设置这个值来改变自己这一方发送的Chunk的最大大小。
大一点的Chunk减少了计算每个chunk的时间从而减少了CPU的占用率,但是它会占用更多的时间在发送上,尤其是在低带宽的网络情况下,很可能会阻塞后面更重要信息的传输。
小一点的Chunk可以减少这种阻塞问题,但小的Chunk会引入过多额外的信息(Chunk中的Header),少量多次的传输也可能会造成发送的间断导致不能充分利用高带宽的优势,因此并不适合在高比特率的流中传输。
在实际发送时应对要发送的数据用不同的Chunk Size去尝试,通过抓包分析等手段得出合适的Chunk大小,并且在传输过程中可以根据当前的带宽信息和实际信息的大小动态调整Chunk的大小,从而尽量提高CPU的利用率并减少信息的阻塞机率。

3.Basic Header(基本的头信息)

Baisc header 是1-3个字节,第一个字节的高2位表示包头的格式低6位表示chunk stream ID

1.fmt取值决定了整个包头header的长度(以下表现的长度均不包含Basic header的长度)

      两位的fmt取值为 0~3,分别代表的意义如下:
      case 0:chunk Msg Header长度为11;
      case 1:chunk Msg Header长度为7;
      case 2:chunk Msg Header长度为3;
      case 3:chunk Msg Header长度为0;

我们以11个字节的完整包头来解释Chunk Msg Header,如图所示

 长度是7 bytes 的chunk head,该类型不包含stream ID,该chunk的streamID和前一个chunk的stream ID是相同的,变长的消息,例如视频流格式,在第一个新的chunk以后使用这种类型,注意其中时间戳部分是相对时间,为何上一个绝对时间之间的差值 如图所示:

3 bytes的chunk head,该类型既不包含stream ID 也不包含消息长度,这种类型用于stream ID和前一个chunk相同,且有固定长度的信息,例如音频流格式,在第一个新的chunk以后使用该类型。如图所示:

 0 bytes的chunk head,这种类型的chunk从前一个chunk得到值信息,当一个单个消息拆成多个chunk时,这些chunk除了第一个以外,其他的都应该使用这种类型, 

2.chunk stream ID决定了Basic header的字节数,首先查看低六位的取值(chunk type)

包含了chunk stream ID(流通道Id)和chunk type(chunk的类型)chunk stream id一般被简写为CSID,用来唯一标识一个特定的流通道。

chunk type决定了Baic Header的长度Basic Header的长度可能是1,2,或3个字节,其中chunk type的长度是固定的(占低6位,注意单位是位,bit),Basic Header的长度取决于CSID的大小,在足够存储这两个字段的前提下最好用尽量少的字节从而减少由于引入Header增加的数据量。
对于chunk type来说:(后6位)

  • (后6位==0)为两个字节,chunk stream id = 64 + 第二个字节值 (64-319)
  • (后6位==1)为三个字节,chunk stream id = 第三字节*256 + 第二字节 + 64(64–65599)
  • (1<后6位<=64)为一个字节,后6bits表示块流ID

RTMP协议支持用户自定义[3,65599]之间的CSID,对于CSID0,1,2由协议保留表示特殊信息。

  • 当Basic Header为1个字节时,CSID占6位,6位最多可以表示64个数,因此这种情况下CSID在[0,63]之间,其中用户可自定义的范围为[3,63]。chunk type的长度固定为2位,因此CSID的长度是(6=8-2)、(14=16-2)、(22=24-2)中的一个。

  • 当Basic Header为2个字节时,CSID占14位,此时协议将与chunk type所在字节的其他位都置为0,剩下的一个字节来表示CSID-64,这样共有8个二进制位来存储CSID,8位可以表示[0,255]共256个数,因此这种情况下CSID在[64,319],其中319=255+64。

  • 当Basic Header为3个字节时,CSID占22位,此时协议将[2,8]字节置为1,余下的16个字节表示CSID-64,这样共有16个位来存储CSID,16位可以表示[0,65535]共65536个数,因此这种情况下CSID在[64,65599],其中65599=65535+64,需要注意的是,Basic Header是采用小端存储的方式,越往后的字节数量级越高,因此通过这3个字节每一位的值来计算CSID时,应该是:<第三个字节的值>x256+<第二个字节的值>+64

4.Message Header(消息的头信息)

包含了要发送的实际信息(可能是完整的,也可能是一部分)的描述信息。Message Header的格式和长度取决于Basic Header的chunk type,共有4种不同的格式,由上面所提到的Basic Header中的fmt字段控制。其中第一种格式可以表示其他三种表示的所有数据,但由于其他三种格式是基于对之前chunk的差量化的表示,因此可以更简洁地表示相同的数据,实际使用的时候还是应该采用尽量少的字节表示相同意义的数据。以下按照字节数从多到少的顺序分别介绍这4种格式的Message Header。

Type=0:

type=0时Message Header占用11个字节,其他三种能表示的数据它都能表示,但在chunk stream的开始的第一个chunk和头信息中的时间戳后退(即值与上一个chunk相比减小,通常在回退播放的时候会出现这种情况)的时候必须采用这种格式。
  • timestamp(时间戳):占用3个字节,因此它最多能表示到16777215=0xFFFFFF=2
    24-1, 当它的值超过这个最大值时,这三个字节都置为1,这样实际的timestamp会转存到Extended Timestamp字段中,接受端在判断timestamp字段24个位都为1时就会去Extended timestamp中解析实际的时间戳。
  • message length(消息数据的长度):占用3个字节,表示实际发送的消息的数据如音频帧、视频帧等数据的长度,单位是字节。注意这里是Message的长度,也就是chunk属于的Message的总数据长度,而不是chunk本身Data的数据的长度。
  • message type id(消息的类型id):占用1个字节,表示实际发送的数据的类型,如8代表音频数据、9代表视频数据。
  • msg stream id(消息的流id):占用4个字节,表示该chunk所在的流的ID,和Basic Header的CSID一样,它采用小端存储的方式,

Type=1:

type=1时Message Header占用7个字节,省去了表示msg stream id的4个字节,表示此chunk和上一次发的chunk所在的流相同,如果在发送端只和对端有一个流链接的时候可以尽量去采取这种格式。
  • timestamp delta:占用3个字节,注意这里和type=0时不同,存储的是和上一个chunk的时间差。类似上面提到的timestamp,当它的值超过3个字节所能表示的最大值时,三个字节都置为1,实际的时间戳差值就会转存到Extended Timestamp字段中,接受端在判断timestamp delta字段24个位都为1时就会去Extended timestamp中解析时机的与上次时间戳的差值。

Type=2:

type=2时Message Header占用3个字节,相对于type=1格式又省去了表示消息长度的3个字节和表示消息类型的1个字节,表示此chunk和上一次发送的chunk所在的流、消息的长度和消息的类型都相同。余下的这三个字节表示timestamp delta,使用同type=1。

Type=3:

0字节!!!好吧,它表示这个chunk的Message Header和上一个是完全相同的,自然就不用再传输一遍了。当它跟在Type=0的chunk后面时,表示和前一个chunk的时间戳都是相同的。什么时候连时间戳都相同呢?就是一个Message拆分成了多个chunk,这个chunk和上一个chunk同属于一个Message。而当它跟在Type=1或者Type=2的chunk后面时,表示和前一个chunk的时间戳的差是相同的。比如第一个chunk的Type=0,timestamp=100,第二个chunk的Type=2,timestamp delta=20,表示时间戳为100+20=120,第三个chunk的Type=3,表示timestamp delta=20,时间戳为120+20=140

5.Extended Timestamp(扩展时间戳):

上面我们提到在chunk中会有时间戳timestamp和时间戳差timestamp delta,并且它们不会同时存在,只有这两者之一大于3个字节能表示的最大数值0xFFFFFF=16777215时,才会用这个字段来表示真正的时间戳,否则这个字段为0。扩展时间戳占4个字节,能表示的最大数值就是0xFFFFFFFF=4294967295。当扩展时间戳启用时,timestamp字段或者timestamp delta要全置为1,表示应该去扩展时间戳字段来提取真正的时间戳或者时间戳差。注意扩展时间戳存储的是完整值,而不是减去时间戳或者时间戳差的值。

6.Chunk Data(块数据):

用户层面上真正想要发送的与协议无关的数据,长度在(0,chunkSize]之间。

(八)RTMP消息类型

在RTMP的chunk流会用一些特殊的值来代表协议的控制消息,它们的Message Stream ID必须为0(代表控制流信息),CSID必须为2,Message Type ID可以为1,2,3,5,6,具体代表的消息会在下面依次说明。控制消息的接受端会忽略掉chunk中的时间戳,收到后立即生效。

1.Set Chunk Size(Message Type ID=1):设置chunk中Data字段所能承载的最大字节数,默认为128B,通信过程中可以通过发送该消息来设置chunk Size的大小(不得小于128B),而且通信双方会各自维护一个chunkSize,两端的chunkSize是独立的。比如当A想向B发送一个200B的Message,但默认的chunkSize是128B,因此就要将该消息拆分为Data分别为128B和72B的两个chunk发送,如果此时先发送一个设置chunkSize为256B的消息,再发送Data为200B的chunk,本地不再划分Message,B接受到Set Chunk Size的协议控制消息时会调整的接受的chunk的Data的大小,也不用再将两个chunk组成为一个Message。
以下为代表Set Chunk Size消息的chunk的Data:
其中第一位必须为0,chunk Size占31个位,最大可代表2147483647=0x7FFFFFFF=231-1,但实际上所有大于16777215=0xFFFFFF的值都用不上,因为chunk size不能大于Message的长度,表示Message的长度字段是用3个字节表示的,最大只能为0xFFFFFF。

2.Abort Message(Message Type ID=2):当一个Message被切分为多个chunk,接受端只接收到了部分chunk时,发送该控制消息表示发送端不再传输同Message的chunk,接受端接收到这个消息后要丢弃这些不完整的chunk。Data数据中只需要一个CSID,表示丢弃该CSID的所有已接收到的chunk。

3.Acknowledgement(Message Type ID=3):当收到对端的消息大小等于窗口大小(Window Size)时接受端要回馈一个ACK给发送端告知对方可以继续发送数据。窗口大小就是指收到接受端返回的ACK前最多可以发送的字节数量,返回的ACK中会带有从发送上一个ACK后接收到的字节数。

4.Window Acknowledgement Size(Message Type ID=5):发送端在接收到接受端返回的两个ACK间最多可以发送的字节数。 

5.Set Peer Bandwidth(Message Type ID=6):限制对端的输出带宽。接受端接收到该消息后会通过设置消息中的Window ACK Size来限制已发送但未接受到反馈的消息的大小来限制发送端的发送带宽。如果消息中的Window ACK Size与上一次发送给发送端的size不同的话要回馈一个Window Acknowledgement Size的控制消息。
  • Hard(Limit Type=0):接受端应该将Window Ack Size设置为消息中的值
  • Soft(Limit Type=1):接受端可以讲Window Ack Size设为消息中的值,也可以保存原来的值(前提是原来的Size小与该控制消息中的Window Ack Size)
  • Dynamic(Limit Type=2):如果上次的Set Peer Bandwidth消息中的Limit Type为0,本次也按Hard处理,否则忽略本消息,不去设置Window Ack Size。

更多见:https://www.jianshu.com/p/b2144f9bbe28

二:FLV协议

FLV的字节序为大端序,在做协议解析的时候一定要注意。

详见:https://blog.csdn.net/luzubodfgs/article/details/78155117

(一)FLV header

FLV header由如下字段组成

其中前4字节中,前三个字节内容固定是FLV,第4字节为版本信息

第5个字节,用来表示是音视频tag

最后4个字节内容固定是9(对FLV版本1来说)

(二)FLV file body

FLV file body很有规律,由一系列的TagSize(前一个tag的大小)和Tag组成,其中:

  1. PreviousTagSize0 总是为0;
  2. tag 由tag header、tag body组成;
  3. 对FLV版本1,tag header固定为11个字节,因此,PreviousTagSize(除第1个)的值为 11 + 前一个tag 的 tag body的大小;

 

(三)FLV tags

FLV tag由 tag header + tag body组成。

tag header如下,总共占据11个字节:

(四)Audio tags

定义如下:

(五)Vedio tags

三:实战RTMP推流FLV

FLV解析器:https://sourceforge.net/projects/flvformatanalysis/

(一)安装rtmp库

http://rtmpdump.mplayerhq.hu/

cd rtmpdump
make
make install

(二)编程思路

1.推流具体步骤

2.librtmp的基本用法

(三)代码实现

#include <stdio.h>
#include <librtmp/rtmp.h>

static FILE* open_flv(char* flv_name){
    FILE* fp = fopen(flv_name,"rb");
    if(!fp){
        printf("Failed to open flv:%s\n", flv_name);
        return NULL;
    }
    
    fseek(fp,9,SEEK_SET);    //跳过9字节的FLV header
    fseek(fp,4,SEEK_CUR);    //跳过4字节的preTagSize

    return fp;    //可以上面直接跳过13字节
}

static RTMP* connect_rtmp_server(char* rtmp_addr){
    //1.创建RTMP对象
    RTMP* rtmp = RTMP_Alloc();
    if(!rtmp){
        printf("Fail to alloc RTMP object!\n");
        goto __ERROR;
    }
    //2.进行初始化
    RTMP_Init(rtmp);

    //3.设置RTMP服务器地址,以及连接超时时间
    rtmp->Link.timeout = 10;
    RTMP_SetupURL(rtmp,rtmp_addr);

    //4.建立连接
    if(!RTMP_Connect(rtmp,NULL)){
        printf("Fail to Connect RTMP Server!\n");
        goto __ERROR;
    }

    //5.设置属性,进行推流
    RTMP_EnableWrite(rtmp);    //如果不设置,则默认为拉流

    //6.创建流
    RTMP_ConnectStream(rtmp,0);    //从0时刻开始

    return rtmp;

__ERROR:
    if(rtmp){
        RTMP_Close(rtmp);
        RTMP_Free(rtmp);
    }

    return NULL;
}

static RTMPPacket* alloc_packet(){
    RTMPPacket* packet = NULL;
    packet = (RTMPPacket*)malloc(sizeof(RTMPPacket));
    if(!packet){
        printf("No memory,Failed to alloc RTMPPacket!\n");
        return NULL;
    }
    if(!RTMPPacket_Alloc(packet,64*1024)){    //64K
        printf("No memory,Failed to alloc RTMPPacket buffer!\n");
        return NULL;
    }    
    RTMPPacket_Reset(packet);    //清零内部


    packet->m_hasAbsTimestamp = 0;    //不使用绝对时间戳
    packet->m_nChannel = 0x4;        //0x04,channel is chunk msg id,it headerType 后6bit,对于a/v data是固定数据04

    return packet;
}

static int read_u8(FILE* fp,unsigned int *u8){
    *u8 = 0;
    if(fread(u8,1,1,fp)!=1)
        return -1;
    return 0;
}

//注意:FLV数据格式为大端存储
//本机在ubuntu下为小端(可以写程序测试)
//所以要进行转换
static int read_u24(FILE* fp,unsigned int *u24){
    unsigned int temp=0;
    if(fread(&temp,1,3,fp)!=3)
        return -1;
    *u24 = ((temp>>16)&0xFF)|((temp<<16)&0xFF0000)|(temp&0xFF00);
    return 0;
}

//注意:FLV数据格式为大端存储
//本机在ubuntu下为小端(可以写程序测试)
//所以要进行转换
static int read_u32(FILE* fp,unsigned int *u32){
    unsigned int temp=0;
    if(fread(&temp,1,4,fp)!=4)
        return -1;
    *u32 = ((temp>>24)&0xFF)|((temp<<24)&0xFF000000)|((temp<<8)&0xFF0000)|((temp>>8)&0xFF00);
    return 0;
}

//读取时间
static int read_ts(FILE* fp,unsigned int *ts){
    unsigned int temp=0;
    if(fread(&temp,1,4,fp)!=4)
        return -1;
    //前面3字节不需要去取扩展时间戳,后面直接加上
    *ts = ((temp>>16)&0xFF)|((temp<<16)&0xFF0000)|(temp&0xFF00)|(temp&0xFF000000);
    return 0;
}


static int read_data(FILE* fp,RTMPPacket** packet){
    //FLV tag由 tag header + tag body组成。
    //先读取header 第一个字节为Tag Type, 8audio 9video 18script
    //2-4字节为Tag body
    //5-7为timestamp,相对时间
    //8字节为时间戳扩展字段(即上面3字节不行,则变为4字节)
    //9-11字节为Stream ID
    int ret=-1;
    size_t datasize=0;
    unsigned int tt,tag_data_size,ts,streamid,tag_pre_size=0;
    if(read_u8(fp,&tt)||read_u24(fp,&tag_data_size)||read_ts(fp,&ts)||read_u24(fp,&streamid))
        return ret;

    datasize = fread((*packet)->m_body,1,tag_data_size,fp);
    printf("read tag header from flv,(tt=%10d;tag_data_size=%10d;ts=%10d;streamid=%10d;datasize=%zu;",tt, tag_data_size,ts,streamid,datasize);

    if(datasize!=tag_data_size){
        printf("Failed to read tag body from flv,(datasize=%zu:tds=%d)\n", datasize,tag_data_size);
        return ret;
    }
    (*packet)->m_headerType = RTMP_PACKET_SIZE_LARGE;    //fmt=0,chunk msg header 为 11字节
    (*packet)->m_nTimeStamp = ts;    //设置时间戳
    (*packet)->m_packetType = tt;    //设置类型
    (*packet)->m_nBodySize = tag_data_size;    //设置body大小

    //最后读取一下pre tag size
    if(read_u32(fp,&tag_pre_size))
        return ret;
    printf("tag_pre_size:%d);\n",tag_pre_size);
    ret = 0;
    return ret;
}

static void send_data(FILE* fp,RTMP* rtmp){
    //不断从FLV中读取数据,构建为RTMPPacket对象,推流到服务端
    RTMPPacket* packet = alloc_packet();
    packet->m_nInfoField2 = rtmp->m_stream_id;
    int n=100,pre_ts=0,diff=0;

    while(1){
        //2.从flv文件读取数据
        if(read_data(fp,&packet)==-1){
            printf("read all data\n");
            break;
        }

        //3.判断RTMP连接是否正确
        if(!RTMP_IsConnected(rtmp)){
            printf("DisConnect....\n");
            break;
        }
        //----实现间隔时间,不要一次性推流所有数据,不然可能使得部分数据被丢弃(应该使用线程+队列+定时器处理)
        diff = packet->m_nTimeStamp - pre_ts;
        if(diff<0)
            diff = 0;

        usleep(diff*1000);
        pre_ts = packet->m_nTimeStamp;

        //4.发送数据
        RTMP_SendPacket(rtmp,packet,0); //0表示不设置队列大小    
    }

    //5.release object

    return;
}    

void publish_stream(){
    char* flv_name = "/home/ld/FFmpeg/develop/charpter_0/video/out2.flv";
    char* rtmp_addr = "rtmp://localhost/mytv/room01";
    //1.读取flv文件
    FILE* fp = open_flv(flv_name);
    //2.连接RTMP服务器
    RTMP* rtmp = connect_rtmp_server(rtmp_addr);
    //3.推流
    send_data(fp,rtmp);

    return;
}

int main(int argc,char* argv[]){
    publish_stream();
    return 0;
}
View Code
gcc 03RTMP.c -o rtmp -lrtmp

(四)FFmpeg学习(七)流媒体服务器搭建

这里使用了nginx,当然srs一样可以

(五)测试

ffplay rtmp://localhost/mytv/room01
./rtmp

 

posted @ 2021-04-25 21:26  山上有风景  阅读(2546)  评论(0编辑  收藏  举报