当前位置: 首页 > article >正文

NDK RTMP直播客户端二

在之前完成的实战项目【FFmpeg音视频播放器】属于拉流范畴,接下来将完成推流工作,通过RTMP实现推流,即直播客户端。简单的说,就是将手机采集的音频数据和视频数据,推到服务器端。

接下来的RTMP直播客户端系列,主要实现红框和紫色部分:

 本节主要内容:

​1.Java层视频编码工作。

2.Native层视频编码器工作。

3.Native层视频推流编码工作。

源码:

NdkPush: 通过RTMP实现推流,直播客户端。

一、Java层视频编码

1)MainActivity:

MainActivity只与中转站NdkPusher打交道,用户操作页面相关功能是调用NdkPusher分发下去;

初始化NdkPusher.java

mNdkPusher = new NdkPusher(this, Camera.CameraInfo.CAMERA_FACING_BACK, 640, 480, 25, 800000);

首次点击【切换摄像头】时,设置Camera与Surface绑定

/**
 * 切换摄像头
 *
 * @param view
 */
public void switchCamera(View view) {
	if (initPermission()) {
		if (!isBind) {
			mNdkPusher.setPreviewDisplay(mSurfaceHolder);
			isBind = true;
		}
		mNdkPusher.switchCamera();
	}
}

点击【开始直播】时,开始直播,并设置rtmp服务器地址

/**
 * 开始直播
 *
 * @param view
 */
public void startLive(View view) {
	mNdkPusher.startLive("rtmp://139.224.136.101/myapp");
}

点击【停止直播】时,停止直播

/**
 * 停止直播
 *
 * @param view
 */
public void stopLive(View view) {
	mNdkPusher.stopLive();
}

页面关闭,释放资源

/**
 * 释放工作
 */
@Override
protected void onDestroy() {
	super.onDestroy();
	mNdkPusher.release();
}

2)NdkPusher:

中转站,分发MainActivity事件和和Native层打交道;

NdkPusher初始化时,主要是的三件事,

①:初始化native层需要的加载,
②:实例化视频通道并传递基本参数(宽高,fps,码率等),
③:实例化音频通道(下一节内容)

public NdkPusher(Activity activity, int cameraId, int width, int height, int fps, int bitrate) {
	native_init();
	// 将this传递给VideoChannel,方便VideoChannel操控native层
	mVideoChannel = new VideoChannel(this, activity, cameraId, width, height, fps, bitrate);
}

分发给视频通道VideoChannel-->SurfaceView与中转站里面的Camera绑定

public void setPreviewDisplay(SurfaceHolder surfaceHolder) {
	mVideoChannel.setPreviewDisplay(surfaceHolder);
}

分发给视频通道VideoChannel-->切换摄像头

public void switchCamera() {
	mVideoChannel.switchCamera();
}

开始直播,调用native层开始直播工作,分发给视频通道VideoChannel开始直播

public void startLive(String path) {
	native_start(path);
	mVideoChannel.startLive();
}

停止直播,调用native层停止直播工作,分发给视频通道VideoChannel停止直播

public void stopLive() {
	mVideoChannel.stopLive();
	native_stop();
}

释放工作,释放native层数据和视频通道VideoChannel

public void release() {
	mVideoChannel.release();
	native_release();
}

与native层通讯函数

// 音频 视频 公用的
private native void native_init(); // 初始化
private native void native_start(String path); // 开始直播start(音频视频通用一套代码) path:rtmp推流地址
private native void native_stop(); // 停止直播
private native void native_release(); // onDestroy--->release释放工作

// 下面是视频独有
public native void native_initVideoEncoder(int width, int height, int mFps, int bitrate); // 初始化x264编码器
public native void native_pushVideo(byte[] data); // 相机画面的数据 byte[] 推给 native层

3)VideoChannel:

视频通道,处理NdkPusher分发下来的事件和将CameraHelper的Camera画面数据推送到native层。

初始化CameraHelper,设置Camera相机预览帮助类,onPreviewFrame(nv21)数据的回调监听和宽高发送改变的监听

public VideoChannel(NdkPusher ndkPusher, Activity activity, int cameraId, int width, int height, int fps, int bitrate) {
	this.mNdkPusher = ndkPusher; // 回调给中转站
	this.mFps = fps; // fps 每秒钟多少帧
	this.bitrate = bitrate; // 码率
	mCameraHelper = new CameraHelper(activity, cameraId, width, height);
	mCameraHelper.setPreviewCallback(this); // 设置Camera相机预览帮助类,onPreviewFrame(nv21)数据的回调监听
	mCameraHelper.setOnChangedSizeListener(this); // 宽高发送改变的监听回调设置
}

调用帮助类:与Surface绑定

public void setPreviewDisplay(SurfaceHolder surfaceHolder) {
	mCameraHelper.setPreviewDisplay(surfaceHolder);
}

调用帮助类-->切换摄像头

public void switchCamera() {
	mCameraHelper.switchCamera();
}

开始直播,只修改标记 让其可以进入if 完成图像数据推送

public void startLive() {
	isLive = true;
}

停止直播,只修改标记 让其可以不要进入if 就不会再数据推送了

public void stopLive() {
	isLive = false;
}

释放,调用帮助类-->停止预览

public void release() {
	mCameraHelper.stopPreview();
}

Camera预览画面的数据,回调到这里,再通过mNdkPusher,将数据推送到native层

@Override
public void onPreviewFrame(byte[] data, Camera camera) {
	// data == nv21 数据
	if (isLive) {
		// 图像数据推送
		mNdkPusher.native_pushVideo(data);
	}
}

Camera发送宽高改变,回调到这里,再通过mNdkPusher,将数据推送到native层

@Override
public void onChanged(int width, int height) {
	// 视频编码器的初始化有关:width,height,fps,bitrate
	mNdkPusher.native_initVideoEncoder(width, height, mFps, bitrate); // 初始化x264编码器
}

4)CameraHelper第一节已完成。

二、Native层视频编码器

1)native-lib.cpp:

处理Java层NdkPusher调用的native函数;

native层初始化工作:

NdkPusher构造函数调用到这里,初始化native层VideoChannel,设置 Camera预览画面的数据推送到native层,videoChannel编码后数据,通过callback回调到native-lib.cpp,加入队列。

extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_push_NdkPusher_native_1init(JNIEnv *env, jobject thiz) {
    // 初始化 VideoChannel
    videoChannel = new VideoChannel();
    // 设置 Camera预览画面的数据推送到native层,videoChannel编码后数据,通过callback回调到native-lib.cpp,加入队列
    videoChannel->setVideoCallback(callback);
    // 设置 队列的释放工作 回调
    packets.setReleaseCallback(releasePackets);
}

videoCallback 函数指针的实现(将编码后数据存放packet到队列)

void callback(RTMPPacket *packet) {
    if (packet) {
        if (packet->m_nTimeStamp == -1) {
            packet->m_nTimeStamp = RTMP_GetTime() - start_time; // 如果是sps+pps 没有时间搓,如果是I帧就需要有时间搓
        }
        packets.push(packet); // 存入队列里面
    }
}

释放RTMPPacket * 包的函数指针实现,T无法释放, 让外界释放

void releasePackets(RTMPPacket **packet) {
    if (packet) {
        RTMPPacket_Free(*packet);
        delete packet;
        packet = nullptr;
    }
}

 初始化x264编码器,Camera宽高改变,回调到这里,首次设置预览时触发;分发到VideoChannel视频通道初始化编码器。

extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_push_NdkPusher_native_1initVideoEncoder(JNIEnv *env, jobject thiz, jint width,
                                                     jint height, jint fps, jint bitrate) {
    if (videoChannel) {
        videoChannel->initVideoEncoder(width, height, fps, bitrate);
    }
}

2)VideoChannel.cpp:

 native层视频通道,初始化x264编码器和处理相机原始数据编码,再回到给native-lib.cpp,加入队列。

初始化 x264 编码器

void VideoChannel::initVideoEncoder(int width, int height, int fps, int bitrate) {
    // 防止编码器多次创建 互斥锁
    pthread_mutex_lock(&mutex);

    mWidth = width;
    mHeight = height;
    mFps = fps;
    mBitrate = bitrate;

    y_len = width * height;
    uv_len = y_len / 4;

    // 防止重复初始化x264编码器
    if (videoEncoder) {
        x264_encoder_close(videoEncoder);
        videoEncoder = nullptr;
    }
    // 防止重复初始化pic_in
    if (pic_in) {
        x264_picture_clean(pic_in);
        DELETE(pic_in);
    }
    // TODO 初始化x264编码器
    x264_param_t param;// x264的参数集

    // 设置编码器属性
    // ultrafast 最快  (直播必须快)
    // zerolatency 零延迟(直播必须快)
    x264_param_default_preset(&param, "ultrafast", "zerolatency");

    // 编码规格:https://wikipedia.tw.wjbk.site/wiki/H.264 看图片
    param.i_level_idc = 32; // 3.2 中等偏上的规格  自动用 码率,模糊程度,分辨率

    // 输入数据格式是 YUV420P  平面模式VVVVVUUUU,如果没有P,  就是交错模式VUVUVUVU
    param.i_csp = X264_CSP_I420;
    param.i_width = width;
    param.i_height = height;

    // 不能有B帧,如果有B帧会影响编码、解码效率(快)
    param.i_bframe = 0;

    // 码率控制方式。CQP(恒定质量),CRF(恒定码率),ABR(平均码率)
    param.rc.i_rc_method = X264_RC_CRF;

    // 设置码率
    param.rc.i_bitrate = bitrate / 1000;

    // 瞬时最大码率 网络波动导致的
    param.rc.i_vbv_max_bitrate = bitrate / 1000 * 1.2;

    // 设置了i_vbv_max_bitrate就必须设置buffer大小,码率控制区大小,单位Kb/s
    param.rc.i_vbv_buffer_size = bitrate / 1000;

    // 码率控制不是通过 timebase 和 timestamp,码率的控制,完全不用时间搓   ,而是通过 fps 来控制 码率(根据你的fps来自动控制)
    param.b_vfr_input = 0;

    // 分子 分母
    // 帧率分子
    param.i_fps_num = fps;
    // 帧率分母
    param.i_fps_den = 1;
    param.i_timebase_den = param.i_fps_num;
    param.i_timebase_num = param.i_fps_den;

    // 告诉人家,到底是什么时候,来一个I帧, 计算关键帧的距离
    // 帧距离(关键帧)  2s一个关键帧   (就是把两秒钟一个关键帧告诉人家)
    param.i_keyint_max = fps * 2;

    // sps序列参数   pps图像参数集,所以需要设置header(sps pps)
    // 是否复制sps和pps放在每个关键帧的前面 该参数设置是让每个关键帧(I帧)都附带sps/pps。
    param.b_repeat_headers = 1;

    // 并行编码线程数
    param.i_threads = 1;

    // profile级别,baseline级别 (把我们上面的参数进行提交)
    x264_param_apply_profile(&param, "baseline");

    // 输入图像初始化
    pic_in = new x264_picture_t(); // 本身空间的初始化
    x264_picture_alloc(pic_in, param.i_csp, param.i_width, param.i_height); // pic_in内部成员初始化等

    // 打开编码器 一旦打开成功,我们的编码器就拿到了
    videoEncoder = x264_encoder_open(&param);
    if (videoEncoder) {
        LOGE("x264编码器打开成功");
    }

    pthread_mutex_unlock(&mutex);
}

三、Native层视频推流编码

1)native-lib.cpp:

开始直播 ---> 启动工作

创建子线程实现:
1.连接流媒体服务器;
2.发包;

extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_push_NdkPusher_native_1start(JNIEnv *env, jobject thiz, jstring path_) {
    /**
     * 创建子线程:
     * 1.连接流媒体服务器;
     * 2.发包;
     */
    if (isStart) {
        return;
    }
    isStart = true;
    const char *path = env->GetStringUTFChars(path_, nullptr);
    // 深拷贝
    char *url = new char(strlen(path) + 1); // C++的堆区开辟 new -- delete
    strcpy(url, path);
    // 创建线程来进行直播
    pthread_create(&pid_start, nullptr, task_start, url);
    env->ReleaseStringUTFChars(path_, path); // 你随意释放,我已经深拷贝了
}

连接RTMP服务器,遍历压缩包队列,将数据发送到RTMP服务器

void *task_start(void *args) {
    char *url = static_cast<char *>(args);
    // RTMPDump API 九部曲
    RTMP *rtmp = nullptr;
    int result; // 返回值判断成功失败
    do {
        // 1.1,rtmp 初始化
        rtmp = RTMP_Alloc();
        if (!rtmp) {
            LOGE("rtmp 初始化失败");
            break;
        }
        // 1.2,rtmp 初始化
        RTMP_Init(rtmp);
        rtmp->Link.timeout = 5; // 设置连接的超时时间(以秒为单位的连接超时)
        // 2,rtmp 设置流媒体地址
        result = RTMP_SetupURL(rtmp, url);
        if (!result) { // result == 0 和 ffmpeg不同,0代表失败
            LOGE("rtmp 设置流媒体地址失败");
            break;
        }
        // 3,开启输出模式
        RTMP_EnableWrite(rtmp);
        // 4,建立连接
        result = RTMP_Connect(rtmp, nullptr);
        if (!result) { // result == 0 和 ffmpeg不同,0代表失败
            LOGE("rtmp 建立连接失败:%d, url: %s", result, url);
            break;
        }
        // 5,连接流
        result = RTMP_ConnectStream(rtmp, 0);
        if (!result) { // result == 0 和 ffmpeg不同,0代表失败
            LOGE("rtmp 连接流失败");
            break;
        }
        start_time = RTMP_GetTime();
        // 准备好了,可以开始向服务器推流了
        readyPushing = true;
        // 队列开始工作
        packets.setWork(1);
        RTMPPacket *packet = nullptr;
        // 从队列里面获取压缩包,直接发给服务器
        while (readyPushing) {
            packets.pop(packet); // 阻塞式
            if (!readyPushing) {
                break;
            }
            // 取不到数据,重新取,可能还没生产出来
            if (!packet) {
                continue;
            }
            // 到这里就是成功的获取队列的ptk了,可以发送给流媒体服务器
            packet->m_nInfoField2 = rtmp->m_stream_id;// 给rtmp的流id
            // 成功取出数据包,发送
            result = RTMP_SendPacket(rtmp, packet, 1); // 1==true 开启内部缓冲
            // packet 你都发给服务器了,可以大胆释放
            releasePackets(&packet);
            if (!result) { // result == 0 和 ffmpeg不同,0代表失败
                LOGE("rtmp 失败 自动断开服务器");
                break;
            }
        }
        releasePackets(&packet); // 只要跳出循环,就释放
    } while (false);
    // 本次一系列释放工作
    isStart = false;
    readyPushing = false;
    packets.setWork(0);
    packets.clear();
    if (rtmp) {
        RTMP_Close(rtmp);
        RTMP_Free(rtmp);
    }
    delete url;

    return nullptr;
}

Camera预览画面的数据,回调到这里,将原始数据进行x264编码后,得到的RTMPPkt(压缩数据)加入队列里面

extern "C"
JNIEXPORT void JNICALL
Java_com_ndk_push_NdkPusher_native_1pushVideo(JNIEnv *env, jobject thiz, jbyteArray data_) {
    if (!videoChannel || !readyPushing) { return; }
    // 把jni ---> C语言的
    jbyte *data = env->GetByteArrayElements(data_, nullptr);
    // data == nv21数据,编码,加入队列
    videoChannel->encodeData(data);
    env->ReleaseByteArrayElements(data_, data, 0); // 释放byte[]
}

2)VideoChannel.cpp:

视频原始数据编码工作

void VideoChannel::encodeData(signed char *data) {
    pthread_mutex_lock(&mutex);

    // 把nv21的y分量 Copy i420的y分量
    memcpy(pic_in->img.plane[0], data, y_len);
    // 把nv21的vuvuvuvu 转化成 i420的 uuuuvvvv
    for (int i = 0; i < uv_len; ++i) {
        // u 数据
        // data + y_len + i * 2 + 1 : 移动指针取 data(nv21) 中 u 的数据
        *(pic_in->img.plane[1] + i) = *(data + y_len + i * 2 + 1);

        // v 数据
        // data + y_len + i * 2 : 移动指针取 data(nv21) 中 v 的数据
        *(pic_in->img.plane[2] + i) = *(data + y_len + i * 2);
    }

    x264_nal_t *nal = nullptr; // 通过H.264编码得到NAL数组(理解)
    int pi_nal; // pi_nal是nal中输出的NAL单元的数量
    x264_picture_t pic_out; // 输出编码后图片 (编码后的图片)

    // 1.视频编码器, 2.nal,  3.pi_nal是nal中输出的NAL单元的数量, 4.输入原始的图片,  5.输出编码后图片
    int ret = x264_encoder_encode(videoEncoder, &nal, &pi_nal, pic_in,
                                  &pic_out); // 进行编码(本质的理解是:编码一张图片)
    if (ret < 0) { // 返回值:x264_encoder_encode函数 返回返回的 NAL 中的字节数。如果没有返回 NAL 单元,则在错误时返回负数和零。
        LOGE("x264编码失败");
        pthread_mutex_unlock(&mutex); // 注意:一旦编码失败了,一定要解锁,否则有概率性造成死锁了
        return;
    }

    // 发送 Packets 入队queue
    // sps(序列参数集) pps(图像参数集) 说白了就是:告诉我们如何解码图像数据
    int sps_len, pps_len; // sps 和 pps 的长度
    uint8_t sps[100]; // 用于接收 sps 的数组定义
    uint8_t pps[100]; // 用于接收 pps 的数组定义
    pic_in->i_pts += 1; // pts显示的时间(+=1 目的是每次都累加下去), dts编码的时间

    // 遍历nal中输出的NAL单元,组件压缩包数据,加入队列
    for (int i = 0; i < pi_nal; ++i) {
        if (nal[i].i_type == NAL_SPS) {
            sps_len = nal[i].i_payload - 4; // 去掉起始码(之前我们学过的内容:00 00 00 01)
            memcpy(sps, nal[i].p_payload + 4, sps_len); // 由于上面减了4,所以+4挪动这里的位置开始
        } else if (nal[i].i_type == NAL_PPS) {
            pps_len = nal[i].i_payload - 4; // 去掉起始码 之前我们学过的内容:00 00 00 01)
            memcpy(pps, nal[i].p_payload + 4, pps_len); // 由于上面减了4,所以+4挪动这里的位置开始

            // sps + pps == 1个压缩包数据
            sendSpsPps(sps, pps, sps_len, pps_len); // pps是跟在sps后面的,这里拿到的pps表示前面的sps肯定拿到了
        } else {
            // 发送 I帧 P帧
            sendFrame(nal[i].i_type, nal[i].i_payload, nal[i].p_payload);
        }
    }
}

组装sps + pps == 1个压缩包数据,存入队列

void VideoChannel::sendSpsPps(uint8_t *sps, uint8_t *pps, int sps_len, int pps_len) {
    // 根据协议设置压缩包数据长度
    int body_size = 5 + 8 + sps_len + 3 + pps_len;

    RTMPPacket *packet = new RTMPPacket; // 开始封包RTMPPacket
    RTMPPacket_Alloc(packet, body_size); // 堆区实例化 RTMPPacket

    int i = 0;
    packet->m_body[i++] = 0x17; // 十六进制转换成二进制,二进制查表 就懂了

    packet->m_body[i++] = 0x00;   // 重点是此字节 如果是1 帧类型(关键帧 非关键帧), 如果是0一定是 sps pps
    packet->m_body[i++] = 0x00;
    packet->m_body[i++] = 0x00;
    packet->m_body[i++] = 0x00;

    // 看图说话
    packet->m_body[i++] = 0x01; // 版本

    packet->m_body[i++] = sps[1];
    packet->m_body[i++] = sps[2];
    packet->m_body[i++] = sps[3];

    packet->m_body[i++] = 0xFF;
    packet->m_body[i++] = 0xE1;

    // 两个字节表达一个长度,需要位移
    // 用两个字节来表达 sps的长度,所以就需要位运算,取出sps_len高8位 再取出sps_len低8位
    //(位运算:https://blog.csdn.net/qq_31622345/article/details/98070787)
    // https://www.cnblogs.com/zhu520/p/8143688.html
    packet->m_body[i++] = (sps_len >> 8) & 0xFF; // 取高8位
    packet->m_body[i++] = sps_len & 0xFF; // 去低8位

    memcpy(&packet->m_body[i], sps, sps_len); // sps拷贝进去了

    i += sps_len; // 拷贝完sps数据 ,i移位,(下面才能准确移位)

    packet->m_body[i++] = 0x01; // pps个数,用一个字节表示

    packet->m_body[i++] = (pps_len >> 8) & 0xFF; // 取高8位
    packet->m_body[i++] = pps_len & 0xFF; // 去低8位

    memcpy(&packet->m_body[i], pps, pps_len); // pps拷贝进去了

    i += pps_len; // 拷贝完pps数据 ,i移位,(下面才能准确移位)

    // 封包处理
    packet->m_packetType = RTMP_PACKET_TYPE_VIDEO; // 包类型 视频包
    packet->m_nBodySize = body_size; // 设置好 sps+pps的总大小
    packet->m_nChannel = 10; // 通道ID,随便写一个,注意:不要写的和rtmp.c(里面的m_nChannel有冲突 4301行)
    packet->m_nTimeStamp = 0; // sps pps 包 没有时间戳
    packet->m_hasAbsTimestamp = 0; // 时间戳绝对或相对 也没有时间搓
    packet->m_headerType = RTMP_PACKET_SIZE_MEDIUM; // 包的类型:数据量比较少,不像帧数据(那就很大了),所以设置中等大小的包

    // packet 存入队列
    videoCallback(packet);
}

发送帧信息,把帧类型 RTMPPacket 存入队列

void VideoChannel::sendFrame(int type, int payload, uint8_t *pPayload) {
    // 去掉起始码 00 00 00 01 或者 00 00 01
    if (pPayload[2] == 0x00){ // 00 00 00 01
        pPayload += 4; // 例如:共10个,挪动4个后,还剩6个
        // 保证 我们的长度是和上的数据对应,也要是6个,所以-= 4
        payload -= 4;
    }else if(pPayload[2] == 0x01){ // 00 00 01
        pPayload +=3; // 例如:共10个,挪动3个后,还剩7个
        // 保证 我们的长度是和上的数据对应,也要是7个,所以-= 3
        payload -= 3;
    }

    // 根据协议设置压缩包数据长度
    int body_size = 5 + 4 + payload;

    RTMPPacket *packet = new RTMPPacket; // 开始封包RTMPPacket
    RTMPPacket_Alloc(packet, body_size); // 堆区实例化 RTMPPacket

    // 区分关键帧 和 非关键帧
    packet->m_body[0] = 0x27; // 普通帧 非关键帧
    if(type == NAL_SLICE_IDR){
        packet->m_body[0] = 0x17; // 关键帧
    }

    packet->m_body[1] = 0x01; // 重点是此字节 如果是1 帧类型(关键帧或非关键帧), 如果是0一定是 sps pps
    packet->m_body[2] = 0x00;
    packet->m_body[3] = 0x00;
    packet->m_body[4] = 0x00;

    // 四个字节表达一个长度,需要位移
    // 用四个字节来表达 payload帧数据的长度,所以就需要位运算
    //(位运算:https://blog.csdn.net/qq_31622345/article/details/98070787)
    // https://www.cnblogs.com/zhu520/p/8143688.html
    packet->m_body[5] = (payload >> 24) & 0xFF;
    packet->m_body[6] = (payload >> 16) & 0xFF;
    packet->m_body[7] = (payload >> 8) & 0xFF;
    packet->m_body[8] = payload & 0xFF;

    memcpy(&packet->m_body[9], pPayload, payload); // 拷贝H264的裸数据

    packet->m_packetType = RTMP_PACKET_TYPE_VIDEO; // 包类型,是视频类型
    packet->m_nBodySize = body_size; // 设置好 关键帧 或 普通帧 的总大小
    packet->m_nChannel = 10; // 通道ID,随便写一个,注意:不要写的和rtmp.c(里面的m_nChannel有冲突 4301行)
    packet->m_nTimeStamp = -1; // 帧数据有时间戳
    packet->m_hasAbsTimestamp = 0; // 时间戳绝对或相对 用不到,不需要
    packet->m_headerType = RTMP_PACKET_SIZE_LARGE ; // 包的类型:若是关键帧的话,数据量比较大,所以设置大包

    // 把最终的 帧类型 RTMPPacket 存入队列
    videoCallback(packet);
}

当压缩数据加入队列后,开启直播创建的子线程将会获取队列的压缩数据,发送到RTMP服务器。

源码:

NdkPush: 通过RTMP实现推流,直播客户端。

视频推流完成,下一节开始音频推流工作。。。


http://www.kler.cn/a/10531.html

相关文章:

  • 有什么初学算法的书籍推荐?
  • 力扣.15 三数之和 three-sum
  • LabVIEW编程基础教学(一)--介绍
  • 【算法一周目】双指针(1)
  • 树莓派安装FreeSWITCH
  • 【MatLab手记】 --从0到了解超超超详过程!!!
  • Metasploit高级技术【第十章】
  • C++篇 ---- 命名空间namespace
  • 华为MatePad有什么好用的软件?
  • 用SSH登陆Centos系统时,命令行最前面显示“的提示符[root@www myapp]”是什么意思?
  • 【博学谷学习记录】超强总结,用心分享丨人工智能 AI项目 统计语言模型之HMM初步学习总结
  • 基于Python实现的深度学习技术在水文水质领域应用
  • Java多线程:定时器Timer
  • C++之入门之缺省参数函数重载引用
  • 【活动】高效学习方法分享
  • 「VS」Visual Studio 常用小技巧
  • 【C语言】迷宫问题
  • CLIP:语言-图像表示之间的桥梁
  • Arcgis Engine之打开MXD文档
  • Linux less 命令
  • SpringBoot ElasticSearch 【SpringBoot系列16】
  • 归排、计排深度理解
  • docker运行服务端性能监控系统Prometheus和数据分析系统Grafana
  • 智慧校园大数据云平台(4)
  • 2023.04.16 学习周报
  • Java学习