基于ffplay改造成自定义多开播放器

本文详细介绍了如何对ffplay进行改造,以实现多实例播放器接口,包括初始化、播放控制、信息回馈和渲染等功能。改造包括将全局变量转换为成员变量,实现音频多路混合,提供C#接口,以及制作WPF播放器。此外,还讨论了播放器的优化,如精准定位,并提供了相应的代码实现。
摘要由CSDN通过智能技术生成

ffplay自定义系列

第一章 自定义播放器接口(本章)
第二章 倍速播放
第三章 dxva2硬解渲染
第四章 提供C#接口
第五章 制作wpf播放器



前言

曾经做一个视频多轨道编辑软件项目的时候,需要定制化的视频播放器,根据多方面的考虑,决定对ffplay进行改造以达到定制化的效果。最近自己个人对其作了重新的设计实现,最终实现了一个跨平台的高度灵活的自定义多实例播放器模块,下面是详细的说明。


一、接口设计

需要多实例的使用ffplay,则必然需要设计一套提供给外部调用的接口,接口的设计按照面向对象的方式以达到多实例。

1、初始化

一个播放器当成一个实例,通过初始化方法得到一个播放器对象,反初始化方法销毁播放器对象。

//播放器对象
typedef  void* NPlay;
//初始化
 NPlay Np_Create();
//反初始化
 void Np_Destroy(NPlay p);

2、播放控制

播放控制包含了,播放、停止、暂停、循环、设置音量、静音、定位等方法,用于外部操控播放器。

//播放
 void Np_Play(NPlay play, const char* url,double startTime);
//停止
 void Np_Stop(NPlay play);
//暂停
 void Np_Pause(NPlay play, int isPaused);
//循环
 void Np_Loop(NPlay play, int isLoop);
//设置音量
 void Np_SetVolume(NPlay player, int value);
//静音
 void Np_Mute(NPlay play, int isMuted);
//定位
 void Np_Seek(NPlay play, double time);

3、信息回馈

打开媒体文件或者接收媒体流时通常需要知道媒体信息如视频格式、音频格式、时长、帧率、比特率等,播放时需要知道播放进度,渲染时需要知道像素格式,播放结束时需要收到通知。

//像素格式
typedef enum 
{
	NP_FORMAT_YU12,
	NP_FORMAT_NV12,
	NP_FORMAT_NV21,
	NP_FORMAT_RGB24,
}Np_PixFormat;
//播放器对象
typedef  void* NPlay;
//回调方法
typedef void(*Np_Callback) (void* userData);
//开始回调方法
typedef void(*Np_BeginCallback) (void* userData, Np_PixFormat*format,int width,int height,double duration);
//播放位置回调方法
typedef void(*Np_PosChangedCallback) (void* userData, double pos);
//设置回调的自定义数据
 void Np_SetUserData(NPlay play,void* userdata);
//设置播放开始回调
 void Np_SetBeginCallback(NPlay play, Np_BeginCallback);
//设置播放结束回调
 void Np_SetEndCallback(NPlay play, Np_Callback);
//设置播放位置回调
 void Np_SetPosChangedCallback(NPlay play, Np_PosChangedCallback);

4、渲染

播放器的渲染是会与外部模块产生关联的一个功能,故需要提供接口。提供的接口有两种形式,一设置窗口句柄(仅限Windows系统),二是自定义渲染。

//视频渲染回调方法
typedef void(*Np_RenderCallback) (void* userData, unsigned char *data[8], int linesize[8],int width,int height);
//设置窗口句柄
 void Np_SetWindow(NPlay  play, void* hwnd);
//设置视频渲染回调
 void Np_SetRenderCallback(NPlay play, Np_RenderCallback);

二、接口实现

1、面向对象化

接口已经设计好了,为了与接口相适应,ffplay的内部实现也需要改造为面向对象,这里有其实两个前提:一是ffplay的实现很大程度遵循了面向对象的思想、二是ffplay的代码结构条理清晰。我们要做的有三个步骤:

(1)、转变成员变量

首先将ffplay.c的所有全局变量,放入VideoState结构体中,接着编译,然后根据编译器提示的错误修改。切记需要记录全局变量的默认值,在实例化方法中,将其对应设置。

(2)、删除全局变量

修改的过程中可以将判断为无用的全局变量删除以常量代替,简化代码的复杂度。

(3)、删除方法

完成上述步骤后,可查找ffplay.c方法的引用关系,与上述接口设计无关的方法可以删除,让代码整洁及易于维护,比如event_loop这个方法是响应界面按键事件的,与上述接口设计无关,就可以删除的。
改造后在变量及设置初始值方法如下:

static void set_default_param(VideoState* s) {
	s->window = 0;
	s->renderer = 0;
	s->hwnd = 0;//窗口句柄
	s->audio_disable = 0;
	s->video_disable = 0;
	s->subtitle_disable = 0;
	s->wanted_stream_spec[0] = 0;
	s->seek_by_bytes = -1;
	s->display_disable = 0;
	s->audio_volume = SDL_MIX_MAXVOLUME;
	s->show_status = 0;
	s->av_sync_type = AV_SYNC_AUDIO_MASTER;
	s->start_time = AV_NOPTS_VALUE;
	s->duration = AV_NOPTS_VALUE;
	s->fast = 0;
	s->genpts = 0;
	s->lowres = 0;
	s->decoder_reorder_pts = -1;
	s->autoexit = 0;
	s->loop = 1;//修改为是否循环播放,原来是循环的次数。
	s->framedrop = -1;
	s->infinite_buffer = -1;
	s->show_mode = SHOW_MODE_NONE;
	s->audio_codec_name = 0;
	s->subtitle_codec_name = 0;
	s->video_codec_name = 0;
	s->rdftspeed = 0.02;
	s->find_stream_info = 1;
	s->audio_callback_time = 0;
	av_init_packet(&s->flush_pkt);
	s->flush_pkt.data = (uint8_t*)&s->flush_pkt;
}

2、音频多路混合

完成了上述内容后,得到的对象化的ffplay还是不能直接使用。我们知道,播放器包含视频渲染、音频播放两个部分。视频渲染通过设置窗口句柄或自定义渲染是可以做到多实例的,但音频的播放则有所不同,音频播放需要打开一个音频设备,然后向音频设备写入音频数据,播放出声音。而一个进程通常只能打开一次音频设备,音频设备通常也只能同时有一个写入。如果考虑通过打开多个音频设备实现多实例显然是不合理的。基于这些情况,这里的解决方案是,只打开一个音频设备,对于多路的音频数据,先混合之后再一同写入到音频设备。
由于ffplay采用了sdl的音频接口且为回调写数据的方式,我只需要先记录每个ffplay的实例,设置一个自己的回调方法,在回调用中遍历所有实例调用ffplay原本的改造面向对象的回调方法(sdl_audio_callback),同时在方法内所有分支都使用SDL_MixAudio方法写入数据。
这里可能会有一个疑问,即音频播放改成了上述多路合并的方式,对时钟同步是否会有影响,是否会导致所有实例的时钟被绑定到了一起。关于这个问题,第一从单个实例角度看除了更换了写入方法,其他和ffplay原本的逻辑是一摸一样的甚至回调频率也是一样的,且各个实例之间的数据是独立的,所以理论上时钟同步是不会相互干扰的。第二,通过了实际测试时钟同步正常没有相互干扰。

#define MUTI_OPEN_NUM 32 //支持多开数
static SDL_mutex* audio_streams_mutex = NULL;
static VideoState* open_audio_streams[MUTI_OPEN_NUM];
static void sdl_audio_callback_sum(void* opaque, Uint8* stream, int len)
{
	int n = 0;
	SDL_LockMutex(audio_streams_mutex);
	for (int i = 0; i < MUTI_OPEN_NUM; i++)
	{
		if (open_audio_streams[i])
		{
			open_audio_streams[i]->audio_callback_index = n++;
			sdl_audio_callback(open_audio_streams[i], stream, len);
		}
	}
	SDL_UnlockMutex(audio_streams_mutex);
}

3、初始化

(1)、初始化

通过初始化得到一个播放对象,相当于c++中的构造函数。有个一个要点,实例化时需要设置改造成成员变量的全局变量的默认值。

NPlay Np_Create() {
	if (!init_global)
	{
		avformat_network_init();
		int	flags = SDL_INIT_EVERYTHING;
		if (SDL_Init(flags)) {
			SDL_Quit();
			return NULL;
		}
		if (audio_streams_mutex == NULL)
		{
			audio_streams_mutex = SDL_CreateMutex();
		}
		memset(open_audio_streams, 0, sizeof(VideoState*) * MUTI_OPEN_NUM);
		init_global = 1;
	}
	VideoState* s = av_mallocz(sizeof(VideoState));
	set_default_param(s);
	return s;
}

(2)、反初始化

将一个播放对象销毁,释放内存及资源,需要从全局记录的对象集合删除销毁的对象,当只剩最有一个对象被销毁时,需要检查关闭音频设备。

void Np_Destroy(NPlay p)
{
	stream_close((VideoState*)p);
	av_free((VideoState*)p);
}

4、播放控制

(1)、播放

调用播放后会开始初始化ffplay的播放相关资源:解复用、视频解码器、音频解码器等, 开启播放线程:解复用线程、视频解码线程、音频解码线程、字幕解码线程、视频渲染线程,开始进行播放,通过直接调用stream_open实现。
播放通常需要输入的参数,为一个url及起始时间,url即播放的地址,可以时文件路径、点播地址、rtsp地址、rtmp地址。起始时间时播放起始的时间,通过播放和定位实现的用户体验可能时不好的,所以需要这个参数。
实现如下:

void Np_Play(NPlay play, const char* url, double startTime)
{
	VideoState* s = (VideoState*)play;
	if (s->ic)
	{
		Np_Stop(play);
	}
	s->start_time = (int64_t)(startTime * AV_TIME_BASE);
	stream_open(s, url, NULL);
}

(2)、停止播放

调用停止播放,销毁当前ffplay的播放资源及线程,通过直接调用stream_close实现。
实现如下:

void Np_Stop(NPlay p)
{
	stream_close((VideoState*)p);
}

(3)、暂停

暂停通过直接调用toggle_pause实现
实现如下:

void Np_Pause(NPlay play, int isPaused)
{
	VideoState* s = (VideoState*)play;
	if (s->paused != isPaused)
		toggle_pause(s);
}

(4)、循环

直接使用loop 字段,通过一些改造,实现。
ffplay的loop字段表示循环的次数,这里需要改为是否循环。
在read_thread中:

if (is->loop/*loop != 1 && (!loop || --loop)*/) {
	stream_seek(is, is->start_time != AV_NOPTS_VALUE ? is->start_time : 0, 0, 0);
}

接口实现如下:

void Np_Loop(NPlay play, int value)
{
	VideoState* s = (VideoState*)play;
	s->loop = value;
}

(5)、设置音量

直接使用audio_volume字段即可。在sdl音频回调方法sdl_audio_callback中会根据audio_volume的值作为参数进行音频混合。
实现如下:

void Np_SetVolume(NPlay play, int value)
{
	VideoState* is = (VideoState*)play;
	if (value < 0)
		value = 0;
	if (value > 100)
		value = 100;
	is->audio_volume = (int)(value * SDL_MIX_MAXVOLUME / 100.0);
}

(6)、静音

直接使用muted字段即可。muted字段在sdl音频回调方法sdl_audio_callback中有判断,如果为真,则不进行写入。
实现如下:

void Np_Mute(NPlay play, int isMuted)
{
	VideoState* s = (VideoState*)play;
	s->muted = isMuted;
}

(7)、定位

直接使用stream_seek方法即可。调用之后,seek_req字段会被置1,在read_thread的解复循环中解复前会判断这个字段,如果为真,则进行定位。
实现如下:

void Np_Seek(NPlay play, double time)
{
	VideoState* is = (VideoState*)play;
	stream_seek(is, (int64_t)(time * AV_TIME_BASE), 0, 0);
}

(8)、倍速播放

基本思路是修改视频通过修改avframe的pts,音频通过使用soundtouch库的settempo方法转换音频数据。这样做的主要原因有两个,一个是通过修改音频的samplerate会导致变速又变调,另一个是使用ffmpeg的滤镜实现音频变速的音频质量非常不好。但后来发现新版本的ffmpeg滤镜效果变好了,音质与soundtouch接近。
ffmpeg滤镜版本的实现如下:
https://blog.csdn.net/u013113678/article/details/120560167
soundtouch版本的实现如下:
https://blog.csdn.net/u013113678/article/details/120521242
sonic版本的实现如下:
https://blog.csdn.net/u013113678/article/details/120519347

5、信息回馈

(1)、开始播放

开始播放是一个事件回调,作用是获取视频信息如分辨率、像素格式,及设置像素格式用于自定义渲染。其回调的时机是在read_thread方法中初始化完所有对象,在进入解复用循环前。
read_thread中添加如下代码:

Np_PixFormat format = NP_FORMAT_YU12;
if (is->viddec.avctx)
{
	switch (is->viddec.avctx->pix_fmt)
	{
	case	AV_PIX_FMT_YUV420P:
		format = NP_FORMAT_YU12;
		break;
	case	AV_PIX_FMT_NV12:
		format = NP_FORMAT_NV12;
		break;
	case	AV_PIX_FMT_NV21:
		format = NP_FORMAT_NV21;
		break;
	case	AV_PIX_FMT_RGB24:
		format = NP_FORMAT_RGB24;
		break;
	default:
		format = NP_FORMAT_YU12;
		break;
	}
}
if (is->begin_callback)
{
	int width = is->viddec.avctx ? is->viddec.avctx->width : 0;
	int height = is->viddec.avctx ? is->viddec.avctx->height : 0;
	double duration = is->ic->duration / (double)AV_TIME_BASE;
	is->begin_callback(is->userdata, &format, width, height, duration);
}
switch (format)
{
case	NP_FORMAT_YU12:
	is->render_format = AV_PIX_FMT_YUV420P;
	break;
case	NP_FORMAT_NV12:
	is->render_format = AV_PIX_FMT_NV12;
	break;
case	NP_FORMAT_NV21:
	is->render_format = AV_PIX_FMT_NV21;
	break;
case	NP_FORMAT_RGB24:
	is->render_format = AV_PIX_FMT_RGB24;
	break;
default:
	is->render_format = AV_PIX_FMT_YUV420P;
	break;
}

接口实现:

void Np_SetBeginCallback(NPlay play, Np_BeginCallback value)
{
	VideoState* is = (VideoState*)play;
	is->begin_callback = value;
}

(2)、播放进度

播放进度的获取时机,不能在解复用线程也不能在解码线程,应该放在渲染或播放线程。具体做法是优先在视频渲染线程回调,当没有视频流时才在sdl音频回调方法中回调。
在video_display方法中回调:

	//回调当前播放时间
	if (is->pos_changed_callback != NULL)
	{
		is->pos_changed_callback(is->userdata, get_master_clock(is));
	}

在sdl_audio_callback中回调:

if (is->viddec.avctx == NULL)
		//如果没有视频流,则在音频播放时回调当前时间
	{
		if (is->pos_changed_callback)
		{
			is->pos_changed_callback(is->userdata, get_master_clock(is));
		}
	}

接口实现:

void Np_SetPosChangedCallback(NPlay play, Np_PosChangedCallback value)
{
	VideoState* is = (VideoState*)play;
	is->pos_changed_callback = value;
}

(3)、播放结束

回调的位置放在read_thread的最后即可:

if (is->end_callback)
{
	is->end_callback(is->userdata);
}

接口实现:

void Np_SetEndCallback(NPlay play, Np_Callback value)
{
	VideoState* is = (VideoState*)play;
	is->end_callback = value;
}

6、渲染

(1)、窗口句柄

在Windows系统,可以实现直接设置窗口句柄的方式渲染视频。内部实现通过sdl关联窗口句柄然后使用sdl进行渲染。
在video_open方法中进行关联:

static int video_open(VideoState* is)
{
//通过外部句柄创建sdl window
	if (!is->window) {
		if (is->hwnd)
		{
			is->window = SDL_CreateWindowFrom(is->hwnd);
		}
		else
			return -1;
	}
//通过外部句柄创建sdl window --end
	if (is->window) {
		SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "linear");
		SDL_RendererInfo info;
		is->renderer = SDL_CreateRenderer(is->window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
		if (!is->renderer) {
			av_log(NULL, AV_LOG_WARNING, "Failed to initialize a hardware accelerated renderer: %s\n", SDL_GetError());
			is->renderer = SDL_CreateRenderer(is->window, -1, 0);
		}
		if (is->renderer) {
			if (!SDL_GetRendererInfo(is->renderer, &info))
				av_log(NULL, AV_LOG_VERBOSE, "Initialized %s renderer.\n", info.name);
		}
		SDL_GetWindowSize(is->window, &is->width, &is->height);
	}

	if (!is->window || !is->renderer) {
		av_log(NULL, AV_LOG_FATAL, "SDL: could not set video mode - exiting\n");
	}
	return 0;
}

接口实现:

void Np_SetWindow(NPlay play, void* hwnd)
{
	VideoState* s = (VideoState*)play;
	s->hwnd = hwnd;
}

(2)、自定义

自定义渲染则是,在视频渲染线程中,不调用ffplay原本的sdl渲染,而回调自定义渲染方法实现如下:

static void video_display(VideoState* is)
{
	//回调当前播放时间
	if (is->pos_changed_callback != NULL)
	{
		is->pos_changed_callback(is->userdata, get_master_clock(is));
	}
	//自定义渲染
	if (is->render_callback)
	{
		Frame* vp;
		vp = frame_queue_peek_last(&is->pictq);
		if (is->render_format != vp->frame->format)
		{
			is->render_convert_ctx = sws_getCachedContext(is->render_convert_ctx,
				vp->frame->width, vp->frame->height, vp->frame->format, vp->frame->width, vp->frame->height,
				is->render_format, sws_flags, NULL, NULL, NULL);
			if (is->render_convert_ctx != NULL)
			{

				int size = av_image_get_buffer_size(is->render_format, vp->width, vp->height, 1);
				if (is->render_buf == NULL || size > is->render_buf_size)
				{
					if (is->render_buf)
					{
						av_free(is->render_buf);
					}
					is->render_buf = av_malloc(size);
					is->render_buf_size = size;
				}
				uint8_t* dst_data[4];
				int dst_linesize[4];
				av_image_fill_arrays(dst_data, dst_linesize, is->render_buf, is->render_format, vp->width, vp->height, 1);
				sws_scale(is->render_convert_ctx, (const uint8_t* const*)vp->frame->data, vp->frame->linesize,
					0, vp->frame->height, dst_data, dst_linesize);
				is->render_callback(is->userdata, is->render_buf, is->render_buf_size, vp->width, vp->height);
			}
		}
		else
		{
			is->render_callback(is->userdata, vp->frame->data, vp->frame->linesize, vp->width, vp->height);
		}
	}
	//自定义渲染 --end
	if (!is->window)
		return;
	SDL_SetRenderDrawColor(is->renderer, 0, 255, 255, 255);
	SDL_RenderClear(is->renderer);
	if (is->video_st)
	{
		video_image_display(is);
	}
	SDL_RenderPresent(is->renderer);
}

接口实现:

void Np_SetRenderCallback(NPlay play, Np_RenderCallback value)
{
	VideoState* is = (VideoState*)play;
	is->render_callback = value;
}

三、优化

通过上述步骤基本可以得到一个,高度定制可用的ffplay播放器,但在使用中发现还是有些地方是需要优化的。

1、精准定位

原本的ffplay没有精准定位功能,以h264为例,如果当前定位的时间不是关键帧,则会自动往前跳到最近的一个idr即gop的首帧,一般的用来看视频的播放器尚可接受,但是作为视频编辑工具使用则不行。所以需要给ffplay拓展此功能。
由于ffplay的定位功能是在read_thread的解复用循环中实现的,因此精准定位当然只能在这个地方进行拓展。
实现思路是,在read_thread中对av_read_frame得到的avpacket的pts与定位的时间进行对比,如果它们的差值大于某个阈值则认为定位不准,需进行重新定位。重新定位的做法是,从上述解复的帧开始继续解码,比较pts,直到pts和定位时间差值小于阈值,回到read_thread的原本流程继续播放。
实现如下:

//gop seek	
		if (seek_time > 0 && pkt->stream_index == is->video_stream && (pkt_time = pkt_ts * av_q2d(is->ic->streams[is->video_stream]->time_base)) < seek_time) {
			//为了确保视频解码线程已经解码完毕,暂时这样,此方法有风险。找代替方案的思路是:下面的代码逻辑需要确保视频解码线程没有在解码。
			SDL_Delay(40);
			AVFrame* frame = av_frame_alloc();
			int got_picture = 0;
			avcodec_send_packet(is->viddec.avctx, pkt);
			avcodec_receive_frame(is->viddec.avctx, frame);
			av_packet_unref(pkt);
			av_frame_unref(frame);
			while (pkt_time < seek_time && pkt_time >= 0) {
				ret = av_read_frame(ic, pkt);
				if (ret >= 0)
				{
					if (pkt->stream_index == is->video_stream)
					{
						ret = avcodec_send_packet(is->viddec.avctx, pkt);

						if (ret == 0)
						{
							got_picture = avcodec_receive_frame(is->viddec.avctx, frame) == 0;
							if (got_picture)
							{
								pkt_time = (pkt->pts == AV_NOPTS_VALUE ? pkt->dts : pkt->pts) * av_q2d(is->ic->streams[is->video_stream]->time_base);
								av_frame_unref(frame);
							}
						}
					}
					av_packet_unref(pkt);
				}
				else
				{
					break;
				}
			}
			av_frame_free(&frame);
			seek_time = 0;
			continue;
		}
		//gop seek -end

四、下载

目前提供动态库下载,C++也可以使用
ffplay自定义播放器封装C#接口

  • 5
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 5
    评论
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

CodeOfCC

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值