基于 FFmpeg 和 SDL 的音视频同步播放器
- 基于 FFmpeg 和 SDL 的音视频同步播放器
- 前置知识
- 音视频同步
- 简介
- 复习DTS、PTS和时间基
- 程序框架
- 主线程
- 解复用线程
- 音频解码播放线程
- 视频解码播放线程
- 音视频同步逻辑
- 源程序
- 结果
- 工程文件下载
- 参考链接
基于 FFmpeg 和 SDL 的音视频同步播放器
前置知识
前情提要:
- 基于 FFmpeg+SDL 的视频播放器的制作
- 最简单的基于 SDL2 的音频播放器
前两篇文章分别基于 FFmpeg+SDL2 实现了音频和视频的播放,要实现一个完整的简易播放器就必须要做到音视频同步播放了,而音视频同步在音视频开发中又是非常重要的知识点,所以在这里记录下音视频同步相关知识的理解。
音视频同步
简介
从前面的学习可以知道,在一个视频文件中,音频和视频都是单独以一条流的形式存在,互不干扰。那么在播放时根据视频的帧率(Frame Rate)和音频的采样率(Sample Rate)通过简单的计算得到其在某一Frame(Sample)的播放时间分别播放,理论上应该是同步的。但是由于机器运行速度,解码效率等等因素影响,很有可能出现音频和视频不同步,且音视频时间差将会呈现线性增长。例如出现视频中人在说话,却只能看到人物嘴动却没有声音,非常影响用户观看体验。
如何做到音视频同步?要知道音视频同步是一个动态的过程,同步是暂时的,不同步才是常态,需要一种随着时间会线性增长的量,视频和音频的播放速度都以该量为标准,播放快了就减慢播放速度;播放慢了就加快播放的速度,在你追我赶中达到同步的状态。
目前主要有三种方式实现同步:
- 将视频和音频同步外部的时钟上,选择一个外部时钟为基准,视频和音频的播放速度都以该时钟为标准。
- 将音频同步到视频上,就是以视频的播放速度为基准来同步音频。
- 将视频同步到音频上,就是以音频的播放速度为基准来同步视频。
比较主流的是第三种,将视频同步到音频上。具体做法是以音频时间为基准,判断视频快了还是慢了,从而调整视频速度。其实是一个动态的追赶与等待的过程。
一般来说,由于某些生物学的原理,人对于声音的敏感度更高,如果频繁地去调整音频会产生杂音让人感觉到刺耳不舒服,而人对图像的敏感度就低很多了,所以一般都会采用第三种方式。
复习DTS、PTS和时间基
-
PTS(Presentation Time Stamp):显示时间戳,指示从packet中解码出来的数据的显示顺序。
-
DTS(Decode Time Stamp):解码时间戳,告诉解码器packet的解码顺序。
音频中二者是相同的,但是视频由于B帧(双向预测)的存在,会造成解码顺序与显示顺序并不相同,也就是视频中DTS与PTS不一定相同。
实例:
实际帧顺序:I B B P
存放帧顺序:I P B B
解码时间戳:1 4 2 3
展示时间戳:1 2 3 4
时间基 FFmpeg 源码:
/*** This is the fundamental unit of time (in seconds) in terms* of which frame timestamps are represented. For fixed-fps content,* timebase should be 1/framerate and timestamp increments should be* identically 1.* This often, but not always is the inverse of the frame rate or field rate* for video.* - encoding: MUST be set by user.* - decoding: the use of this field for decoding is deprecated.* Use framerate instead.*/
AVRational time_base;
/**
* rational number numerator/denominator
*/
typedef struct AVRational{int num; ///< numeratorint den; ///< denominator
} AVRational;
时间基是一个分数,以秒为单位,num为分子,den为分母。
那它到底表示的是什么意思呢?以帧率为例,如果它的时间基是1/50秒,那么就表示每隔1/50秒显示一帧数据,也就是每1秒显示50帧,帧率为50FPS。
FFmpeg 提供了时间基的计算方法:
/**
* Convert rational to double.
* @param a rational to convert
* @return (double) a
*/
static inline double av_q2d(AVRational a){return a.num / (double) a.den;
}
每一帧数据都有对应的PTS,在播放视频或音频的时候我们需要将PTS时间戳转化为以秒为单位的时间,用来最后的展示,视频中某帧的显示时间的计算方式为:
time = pts * av_q2d(time_base);
程序框架
主线程
- 加载视频文件,查找音视频流信息
- 初始化音视频解码器
- 初始化SDL并设置相关的音视频参数
- 创建解复用线程,音频解码播放线程,视频解码播放线程
- 然后进入SDL窗口的事件循环,等待退出事件
解复用线程
- 循环读文件流,每次从文件流中读取一帧数据
- 根据帧类型放入相应的队列中
音频解码播放线程
- 从音频队列中取出一帧
- 将取到的数据送至音频解码器中
- 循环从解码器中取解码音频帧
- 将解码数据转换成packed形式,也就是LRLRLR…
- 等待SDL音频回调播放音频完成,回到1
视频解码播放线程
- 从视频队列中取出一帧
- 将取到的数据送至视频解码器中
- 循环从解码器中取解码视频帧
- 渲染视频帧到SDL窗口中
- 计算视频帧的pts和持续时间
- 根据音频帧和视频帧的差值计算延时
- 延时计算的时长后回到1
音视频同步逻辑
- 如果当前视频帧与音频帧的播放时间差值小于或等于视频帧持续时间,则表示音视频同步,正常延时。delay = duration。
- 如果视频帧比音频帧快,且大于视频帧一帧的时长,延时2倍的正常延时。delay = 2 * delay。
- 如果视频帧比音频帧慢,且大于视频帧一帧的时长,则立即播放下一帧。delay = 0。
源程序
环境:
- ffmpeg-win32-4.2.2
- SDL2
- Visual Studio 2015
下载地址:
- ffmpeg-win32-4.2.2.zip
- SDL2 库 - from 雷霄骅.zip
完整程序:
// Simplest FFmpeg Sync Player.cpp : 定义控制台应用程序的入口点。
//#include "stdafx.h"#pragma warning(disable:4996)#include <stdio.h>#define __STDC_CONSTANT_MACROSextern "C"
{
#include "libavformat/avformat.h"
#include "libavutil/time.h"
#include "SDL2/SDL.h"
}// 报错:
// LNK2019 无法解析的外部符号 __imp__fprintf,该符号在函数 _ShowError 中被引用
// LNK2019 无法解析的外部符号 __imp____iob_func,该符号在函数 _ShowError 中被引用// 解决办法:
// 包含库的编译器版本低于当前编译版本,需要将包含库源码用vs2017重新编译,由于没有包含库的源码,此路不通。
// 然后查到说是stdin, stderr, stdout 这几个函数vs2015和以前的定义得不一样,所以报错。
// 解决方法呢,就是使用{ *stdin,*stdout,*stderr }数组自己定义__iob_func()
#pragma comment(lib,"legacy_stdio_definitions.lib")
extern "C"
{FILE __iob_func[3] = { *stdin, *stdout, *stderr };
}char av_error[AV_ERROR_MAX_STRING_SIZE] = { 0 };
#define av_err2str(errnum) av_make_error_string(av_error, AV_ERROR_MAX_STRING_SIZE, errnum)#define MAX_VIDEO_PIC_NUM 1 // 最大缓存解码图片数#define AV_SYNC_THRESHOLD 0.01 // 同步最小阈值
#define AV_NOSYNC_THRESHOLD 10.0 // 不同步阈值// Packet 队列
typedef struct PacketQueue
{AVPacketList* first_pkt, *last_pkt; // 头、尾指针int nb_packets; // packet 计数器SDL_mutex* mutex; // SDL 互斥量
} PacketQueue;// 音视频同步时钟模式
enum {AV_SYNC_AUDIO_MASTER, // 设置音频为主时钟,将视频同步到音频上,默认选项AV_SYNC_VIDEO_MASTER, // 设置视频为主时钟,将音频同步到视频上,不推荐AV_SYNC_EXTERNAL_CLOCK, // 选择一个外部时钟为基准,不推荐
};// Buffer:
// |-----------|-------------|
// chunk-------pos---len-----|
static Uint8* audio_chunk;
static Uint32 audio_len;
static Uint8* audio_pos;SDL_Window* sdlWindow = nullptr; // 窗口
SDL_Renderer* sdlRenderer = nullptr; // 渲染器
SDL_Texture* sdlTexture = nullptr; // 纹理
SDL_Rect sdlRect; // 渲染显示面积AVFormatContext* pFormatCtx = NULL;
AVPacket* pkt;
AVFrame* video_frame, *audio_frame;
int ret;
int video_index = -1, audio_index = -1;// 输入文件路径
char in_filename[] = "cuc_ieschool.mp4";int frame_width = 1280;
int frame_height = 720;// 视频解码
AVCodec* video_pCodec = nullptr;
AVCodecContext* video_pCodecCtx = nullptr;typedef struct video_pic
{AVFrame frame;float clock; // 显示时钟float duration; // 持续时间int frame_NUM; // 帧号
} video_pic;video_pic v_pic[MAX_VIDEO_PIC_NUM]; // 视频解码最多保存四帧数据
int pic_count = 0; // 已存储图片数量// 音频解码
AVCodec* audio_pCodec = nullptr;
AVCodecContext* audio_pCodecCtx = nullptr;PacketQueue video_pkt_queue; // 视频帧队列
PacketQueue audio_pkt_queue; // 音频帧队列// 同步时钟,设置音频为主时钟
int av_sync_type = AV_SYNC_AUDIO_MASTER;int64_t audio_callback_time;double video_clock; // 视频时钟
double audio_clock; // 音频时钟// SDL 音频参数结构
SDL_AudioSpec audio_spec;// 初始化 SDL 并设置相关的音视频参数
int initSDL();
// 关闭 SDL 并释放资源
void closeSDL();
// SDL 音频回调函数
void fill_audio_pcm2(void* udata, Uint8* stream, int len);// fltp 转为 packed 形式
void fltp_convert_to_f32le(float* f32le, float* fltp_l, float* fltp_r, int nb_samples, int channels)
{for (int i = 0; i < nb_samples; i++){f32le[i * channels] = fltp_l[i];f32le[i * channels + 1] = fltp_r[i];}
}// 将一个 AVPacket 放入相应的队列中
void put_AVPacket_into_queue(PacketQueue *q, AVPacket* packet)
{SDL_LockMutex(q->mutex); // 上锁AVPacketList* temp = nullptr;temp = (AVPacketList*)av_malloc(sizeof(AVPacketList));if (!temp){printf("Malloc an AVPacketList error.\n");return;}temp->pkt = *packet;temp->next = nullptr;if (!q->last_pkt)q->first_pkt = temp;elseq->last_pkt->next = temp;q->last_pkt = temp;q->nb_packets++;SDL_UnlockMutex(q->mutex); // 解锁
}// 从 AVPacket 队列中取出第一个帧
static void packet_queue_get(PacketQueue* q, AVPacket *pkt2)
{while (true){AVPacketList* pkt1 = nullptr;// 一直取,直到队列中有数据,就返回pkt1 = q->first_pkt;if (pkt1){SDL_LockMutex(q->mutex); // 上锁q->first_pkt = pkt1->next;if (!q->first_pkt)q->last_pkt = nullptr;q->nb_packets--;SDL_UnlockMutex(q->mutex); // 解锁// pkt2 指向我们取的帧*pkt2 = pkt1->pkt;// 释放帧av_free(pkt1);break;}else{// 队列里暂时没有帧,等待SDL_Delay(1);}}return;
}// 视频解码播放线程
int video_play_thread(void * data)
{AVPacket video_pkt = { 0 };// 取数据while (true){// 从视频帧队列中取出一个 AVPacketpacket_queue_get(&video_pkt_queue, &video_pkt);// Send packet to decoderret = avcodec_send_packet(video_pCodecCtx, &video_pkt);if (ret < 0){fprintf(stderr, "Error sending a packet to video decoder.\n", av_err2str(ret));return -1;}while (ret >= 0){// Receive frame from decoderret = avcodec_receive_frame(video_pCodecCtx, video_frame);if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF)break;else if (ret < 0){fprintf(stderr, "Error receiving frame from video decoder.\n");break;}// printf("帧数:%3d\n", video_pCodecCtx->frame_number);fflush(stdout); // 清空输出缓冲区,并把缓冲区内容输出// video_clock = video_pCodecCtx->frame_number * durationvideo_clock = av_q2d(video_pCodecCtx->time_base) * video_pCodecCtx->ticks_per_frame * 1000 * video_pCodecCtx->frame_number;// printf("视频时钟:%f ms\n", video_clock);double duration = av_q2d(video_pCodecCtx->time_base) * video_pCodecCtx->ticks_per_frame * 1000;// 设置纹理的数据SDL_UpdateYUVTexture(sdlTexture, nullptr, // 矩形区域 rect,为 nullptr 表示全部区域video_frame->data[0], video_frame->linesize[0],video_frame->data[1], video_frame->linesize[1],video_frame->data[2], video_frame->linesize[2]);sdlRect.x = 0;sdlRect.y = 0;sdlRect.w = frame_width;sdlRect.h = frame_height;// 清理渲染器缓冲区SDL_RenderClear(sdlRenderer);// 将纹理拷贝到窗口渲染平面上SDL_RenderCopy(sdlRenderer, sdlTexture, NULL, &sdlRect);// 翻转缓冲区,前台显示SDL_RenderPresent(sdlRenderer);// 调整播放下一帧的延迟时间,以实现同步double delay = duration;double diff = video_clock - audio_clock; // 时间差if (fabs(diff) <= duration) // 时间差在一帧范围内表示正常,延时正常时间delay = duration;else if (diff > duration) // 视频时钟比音频时钟快,且大于一帧的时间,延时 2 倍delay *= 2;else if (diff < -duration) // 视频时钟比音频时钟慢,且超出一帧时间,立即播放当前帧delay = 0;printf("frame: %d, delay: %lf ms\n", video_pCodecCtx->frame_number, delay);SDL_Delay(delay);}}return 0;
}// 音频解码播放线程
int audio_play_thread(void* data)
{AVPacket audio_pkt = { 0 };// 取数据while (true){// 从音频帧队列中取出一个 AVPacketpacket_queue_get(&audio_pkt_queue, &audio_pkt);// Send packet to decoderret = avcodec_send_packet(audio_pCodecCtx, &audio_pkt);if (ret < 0){fprintf(stderr, "Error sending a packet to audio decoder.\n", av_err2str(ret));return -1;}while (ret >= 0){// Receive frame from decoderret = avcodec_receive_frame(audio_pCodecCtx, audio_frame);if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF)break;else if (ret < 0){fprintf(stderr, "Error receiving frame from audio decoder.\n");break;}/** 下面是得到解码后的裸流数据进行处理,根据裸流数据的特征做相应的处理,* 如 AAC 解码后是 PCM ,H.264 解码后是 YUV,等等。*/// 根据采样格式,获取每个采样所占的字节数int data_size = av_get_bytes_per_sample(audio_pCodecCtx->sample_fmt);if (data_size < 0){// This should not occur, checking just for paranoiafprintf(stderr, "Failed to calculate data size.\n");break;}// nb_samples: AVFrame 的音频帧个数,channels: 通道数int pcm_buffer_size = data_size * audio_frame->nb_samples * audio_pCodecCtx->channels;uint8_t* pcm_buffer = (uint8_t*)malloc(pcm_buffer_size);memset(pcm_buffer, 0, pcm_buffer_size);// 转换为 packed 模式fltp_convert_to_f32le((float*)pcm_buffer, (float*)audio_frame->data[0], (float*)audio_frame->data[1],audio_frame->nb_samples, audio_pCodecCtx->channels);// 使用 SDL 播放// Set audio buffer (PCM data)audio_chunk = pcm_buffer;audio_len = pcm_buffer_size;audio_pos = audio_chunk;audio_clock = audio_frame->pts * av_q2d(audio_pCodecCtx->time_base) * 1000;// printf("音频时钟: %f ms\n", audio_clock);// Wait until finishwhile (audio_len > 0){// 使用 SDL_Delay 进行 1ms 的延迟,用当前缓存区剩余未播放的长度大于 0 结合前面的延迟进行等待SDL_Delay(1);}free(pcm_buffer);}}return 0;
}// 解复用线程
int open_file_thread(void* data)
{// 读取一个 AVPacketwhile (av_read_frame(pFormatCtx, pkt) >= 0){if (pkt->stream_index == video_index){// 加入视频队列put_AVPacket_into_queue(&video_pkt_queue, pkt);}else if (pkt->stream_index == audio_index){// 加入音频队列put_AVPacket_into_queue(&audio_pkt_queue, pkt);}else{// 当我们从数据队列中取出数据使用完后,需要释放空间(AVPacket)// 否则被导致内存泄漏,导致程序占用内存越来越大av_packet_unref(pkt);}}return 0;
}int main(int argc, char * argv[])
{// 打开媒体文件ret = avformat_open_input(&pFormatCtx, in_filename, 0, 0);if (ret < 0){printf("Couldn't open input file.\n");return -1;}// 读取媒体文件信息,给 pFormatCtx 赋值ret = avformat_find_stream_info(pFormatCtx, 0);if (ret < 0){printf("Couldn't find stream information.\n");return -1;}video_index = -1;for (int i = 0; i < pFormatCtx->nb_streams; i++){if (pFormatCtx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO){video_index = i;break;}}if (video_index == -1){printf("Didn't find a video stream.\n");return -1;}audio_index = -1;for (size_t i = 0; i < pFormatCtx->nb_streams; i++){if (pFormatCtx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO){audio_index = i;break;}}if (audio_index == -1){printf("Didn't find an audio stream.\n");return -1;}// Output Infoprintf("--------------- File Information ----------------\n");av_dump_format(pFormatCtx, 0, in_filename, 0); // 打印输入文件信息printf("-------------------------------------------------\n");// 根据视频流信息的 codec_id 找到对应的解码器video_pCodec = avcodec_find_decoder(pFormatCtx->streams[video_index]->codecpar->codec_id);if (!video_pCodec){printf("Video codec not found.\n");return -1;}// 分配视频解码器上下文video_pCodecCtx = avcodec_alloc_context3(video_pCodec);// 拷贝视频流信息到视频解码器上下文中avcodec_parameters_to_context(video_pCodecCtx, pFormatCtx->streams[video_index]->codecpar);// 得到视频的宽度和高度frame_width = pFormatCtx->streams[video_index]->codecpar->width;frame_height = pFormatCtx->streams[video_index]->codecpar->height;// 打开视频解码器和关联解码器上下文if (avcodec_open2(video_pCodecCtx, video_pCodec, nullptr)){printf("Could not open video codec.\n");return -1;}// 根据音频流信息的 codec_id 找到对应的解码器audio_pCodec = avcodec_find_decoder(pFormatCtx->streams[audio_index]->codecpar->codec_id);if (!audio_pCodec){printf("Audio codec not found.\n");return -1;}// 分配音频解码器上下文audio_pCodecCtx = avcodec_alloc_context3(audio_pCodec);// 拷贝音频流信息到音频解码器上下文中avcodec_parameters_to_context(audio_pCodecCtx, pFormatCtx->streams[audio_index]->codecpar);// 打开音频解码器和关联解码器上下文if (avcodec_open2(audio_pCodecCtx, audio_pCodec, nullptr)){printf("Could not open audio codec.\n");return -1;}// 申请一个 AVPacket 结构pkt = av_packet_alloc();// 申请一个 AVFrame 结构用来存放解码后的数据video_frame = av_frame_alloc();audio_frame = av_frame_alloc();// 初始化 SDLinitSDL();// 创建互斥量video_pkt_queue.mutex = SDL_CreateMutex();audio_pkt_queue.mutex = SDL_CreateMutex();// 设置 SDL 音频播放参数audio_spec.freq = audio_pCodecCtx->sample_rate; // 采样率audio_spec.format = AUDIO_F32LSB; // 音频数据采样格式audio_spec.channels = audio_pCodecCtx->channels; // 通道数audio_spec.silence = 0; // 音频缓冲静音值audio_spec.samples = audio_pCodecCtx->frame_size; // 每一帧的采样点数量,基本是 512、1024,设置不合适可能会导致卡顿audio_spec.callback = fill_audio_pcm2; // 音频播放回调// 打开系统音频设备if (SDL_OpenAudio(&audio_spec, NULL) < 0){printf("Can't open audio.\n");return -1;}// 开始播放SDL_PauseAudio(0);// 创建 SDL 线程SDL_CreateThread(open_file_thread, "open_file", nullptr);SDL_CreateThread(video_play_thread, "video_play", nullptr);SDL_CreateThread(audio_play_thread, "audio_play", nullptr);bool quit = false;SDL_Event e;while (quit == false){while (SDL_PollEvent(&e) != 0){if (e.type == SDL_QUIT){quit = true;break;}}}// 销毁互斥量SDL_DestroyMutex(video_pkt_queue.mutex);SDL_DestroyMutex(audio_pkt_queue.mutex);// 关闭 SDLcloseSDL();// 释放 FFmpeg 相关资源avcodec_close(video_pCodecCtx);avcodec_free_context(&video_pCodecCtx);avcodec_close(audio_pCodecCtx);avcodec_free_context(&audio_pCodecCtx);av_packet_free(&pkt);av_frame_free(&audio_frame);av_frame_free(&video_frame);avformat_close_input(&pFormatCtx);return 0;
}// SDL 初始化
int initSDL()
{if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_TIMER)){printf("Could not initialize SDL - %s\n", SDL_GetError());return -1;}// 创建窗口 SDL_WindowsdlWindow = SDL_CreateWindow("Simplest FFmpeg Sync Player", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,frame_width, frame_height, SDL_WINDOW_SHOWN);if (sdlWindow == nullptr){printf("SDL: Could not create window - exiting:%s\n", SDL_GetError());return -1;}// 创建渲染器 SDL_RenderersdlRenderer = SDL_CreateRenderer(sdlWindow, -1, 0);if (sdlRenderer == nullptr){printf("SDL: Could not create renderer - exiting:%s\n", SDL_GetError());return -1;}// 创建纹理 SDL_Texture// IYUV: Y + U + V (3 planes)// YV12: Y + V + U (3 planes)sdlTexture = SDL_CreateTexture(sdlRenderer, SDL_PIXELFORMAT_IYUV, SDL_TEXTUREACCESS_STREAMING, frame_width, frame_height);if (sdlTexture == nullptr){printf("SDL: Could not create texture - exiting:%s\n", SDL_GetError());return -1;}sdlRect.x = 0;sdlRect.y = 0;sdlRect.w = frame_width;sdlRect.h = frame_height;return 0;
}/* SDL 音频回调函数
*
* 开始播放后,会有音频其他子线程来调用回调函数,进行音频数据的补充,经过测试每次补充 4096 个字节
* The audio function callback takes the following parameters:
* stream: A pointer to the audio buffer to be filled
* len: The length (in bytes) of the audio buffer
*
*/
void fill_audio_pcm2(void* udata, Uint8* stream, int len)
{// 获取当前系统时钟audio_callback_time = av_gettime();// SDL 2.0SDL_memset(stream, 0, len);if (audio_len == 0) /* Only play if we have data left */return;/* Mix as much data as possible */len = ((Uint32)len > audio_len ? audio_len : len);/* 混音播放函数* dst: 目标数据,这个是回调函数里面的 stream 指针指向的,直接使用回调的 stream 指针即可* src: 音频数据,这个是将需要播放的音频数据混到 stream 里面去,那么这里就是我们需要填充的播放的数据* len: 音频数据的长度* volume: 音量,范围 0~128 ,SAL_MIX_MAXVOLUME 为 128,设置的是软音量,不是硬件的音响*/SDL_MixAudio(stream, audio_pos, len, SDL_MIX_MAXVOLUME / 2);audio_pos += len;audio_len -= len;
}// 关闭 SDL
void closeSDL()
{// 关闭音频设备SDL_CloseAudio();// 释放 SDL 资源SDL_DestroyWindow(sdlWindow);sdlWindow = nullptr;SDL_DestroyRenderer(sdlRenderer);sdlRenderer = nullptr;SDL_DestroyTexture(sdlTexture);sdlTexture = nullptr;// 退出 SDL 系统SDL_Quit();
}
结果
测试发现,该程序能成功解码各种格式的视频,但只能正确播放 AAC 音频。
工程文件下载
GitHub:UestcXiye / Simplest-FFmpeg-Sync-Player
CSDN:Simplest FFmpeg Sync Player.zip
参考链接
- 《 100行代码实现最简单的基于FFMPEG+SDL的视频播放器(SDL1.x)》
- FFmpeg音视频同步
- 使用FFMPEG和SDL2实现音视频同步的简易视频播放器