FFmpeg4.0+SDL2.0笔记05:Synching Video

2021-04-13 17:59:00 浏览数 (1)

环境

背景:在系统性学习FFmpeg时,发现官方推荐教程还是15年的,不少接口已经弃用,大版本也升了一级,所以在这里记录下FFmpeg4.0 SDL2.0的学习过程。

win10,VS2019,FFmpeg4.3.2,SDL2.0.14

原文地址:http://dranger.com/ffmpeg/tutorial05.html

警告

该教程的所有同步代码都是当年从ffplay.c拉过来的,虽然这篇教程的代码还能正常工作,但ffplay.c已经迭代了多个版本,一些同步策略也变了,强烈建议后面再去看看最新的ffplay源码。

视频是如何同步的

现在我们已经做出了一个视频播放器,但基本没法看。它虽然能播音视频,但播起来像脱了缰的野马,玩了命地去跑完整个视频进度,我们该如何给这匹野马缚上缰绳呢?

PTS和DTS

幸运的是,音视频流都有相关的控制信息告诉我们在什么时候,用多快的速度去播哪一帧。比如音频流有采样率sample rate,视频流有帧率fps。但仅仅靠帧率来控制视频播放是不够的,因为视频还有可能和音频失去同步,造成音画不同步现象。因此,数据流中有两个重要参数DTS(decoding timestamp)PTS(presentation timestamp),DTS告诉解码器何时解码,PTS告诉播放器何时播放。

为什么要有DTS和PTS?首先需要理解编码后的数据是如何存储的,比如MPEG格式,视频帧分为三种类型:I帧,P帧,B帧。I帧即关键帧,可以直接解码出完整图像,P帧即预测帧,它依赖前面最近的I帧或P帧才能还原出完整图像,B帧即双向帧,跟P帧差不多,但必须依赖前一帧和后一帧才能还原出完整图像。这也就是为什么avcodec_receive_frame不是每次都能拿到图像的原因。

假如现在的播放顺序是这样的:I,B,B,P。我们需要先解出I帧和P帧才能再去解B帧,因此,实际的存储和解码顺序是这样的:I,P,B,B,必须要有PTS和DTS才能完成整个解码播放过程。

代码语言:javascript复制
   PTS:1 4 2 3
   DTS:1 2 3 4
Stream:I P B B

同步

知道了大致原理,该如何让视频以正常速度播放呢?思路是这样的:在显示完一帧后,我们预测下一帧何时显示,然后注册定时事件去显示下一帧,并重复以上步骤。这里有两个问题:

第一个是如何预测下一帧的PTS。我们可以直接加上1/fps,比如fps是25,就加上40ms。一般情况下这么做是没问题的,但总有例外:部分视频播放时需要把当前帧重复显示一到多次(为了压制视频大小真是什么招都有),我们还需要把这点也计算进去。

第二个是同步哪条时间线?我们一共有三条时间线,视频时间,音频时间,系统时间,可惜它们都不是完美的。这里我们以音频时间为基准,同步视频。

Coding:获取视频PTS

原先教程里使用av_frame_get_best_effort_timestamp来获取解出的那一帧PTS,现在这个接口被彻底弃用了,不用那么麻烦,avcodec_receive_frame(pVideoState->pAudioCodecCtx, frame)成功后直接用frame->pts即可。

代码语言:javascript复制
    // 取实际运行时的一个例子
    // 119285 == frame->pts
    // 1/90000 == av_q2d(pVideoState->pVideoStream->time_base
    // pts = 1.3253888888888889 (s)
    double pts = frame->pts;
    pts *= av_q2d(pVideoState->pVideoStream->time_base);)

注意,frame->pts是根据视频采样率(time_base)来衡量的,为了转化成秒,还需要除以采样率。

Coding:使用PTS同步

VideoState里加上了一堆变量,这里先放出来

代码语言:javascript复制
typedef struct VideoState{
    AVFormatContext* pFormatCtx;
    //audio
    int iAudioStreamIndex;
    AVStream* pAudioStream;
    AVCodecContext* pAudioCodecCtx;
    SwrContext* pSwrCtx;
    PacketQueue audioQueue;
    uint8_t audioBuf[kMaxAudioFrameSize * 3 / 2];
    unsigned audioBufSize;
    unsigned audioBufIndex;
    AVPacket audioPacket;
    AVFrame audioFrame;
    uint8_t outAudioBuf[kMaxAudioFrameSize * 2];
    double audioClock;
    //video
    int iVideoStreamIndex;
    AVStream* pVideoStream;
    AVCodecContext* pVideoCodecCtx;
    PacketQueue videoQueue;
    VideoPicture videoPicture;
    SDL_mutex* pVideoFrameMutex;
    SDL_cond* pVideoFrameCond;

    double videoClock;
    double frameLastPts;
    double frameLastDelay;
    double frameTimer;

    SDL_Window* pScreen;
    SDL_Renderer* pRenderer;
    SDL_Texture* pTexture;

    SDL_Thread* pParseThread;
    SDL_Thread* pVideoDecodeThread;

    char filename[1024];
    int quit;
}VideoState;

现在来解决同步问题,我们定义一个synchronizeVideo()来实时更新PTS,该函数还会处理pts为0的异常情况。同时还会预测下一帧的pts并保存下来。

代码语言:javascript复制
double synchronizeVideo(VideoState* pVideoState, AVFrame* srcFrame)
{
    double pts = srcFrame->pts;
    double frameDelay;

    //万一pts为0,我们用预测的videoClock来填补
    pts *= av_q2d(pVideoState->pVideoStream->time_base);
    if (pts) {
        pVideoState->videoClock = pts;
    }
    else {
        pts = pVideoState->videoClock;
    }
    
    //预测下一帧的pts,这个预测的pts正常情况是没用的,只有在下一帧pts为0的异常情况下才会用到
    frameDelay = av_q2d(pVideoState->pVideoCodecCtx->time_base);
    frameDelay  = srcFrame->repeat_pict * (frameDelay * 0.5);
    pVideoState->videoClock  = frameDelay;

    return pts;
}

拿到pts后,更新到缓存队列中等待渲染

代码语言:javascript复制
typedef struct VideoPicture {
	AVFrame frame;
	bool empty;
	double pts;
}VideoPicture;

			pts = synchronizeVideo(pVideoState, pFrame);

			SDL_LockMutex(pVideoState->pVideoFrameMutex);
			while (!pVideoState->videoPicture.empty && !pVideoState->quit) {
				SDL_CondWait(pVideoState->pVideoFrameCond, pVideoState->pVideoFrameMutex);
			}
			SDL_UnlockMutex(pVideoState->pVideoFrameMutex);

			if (pVideoState->quit)return -1;

            //这里我偷懒了,没用队列,并且直接赋值了frame,虽然在临界区的保护下暂时不会有问题     
			pVideoState->videoPicture.frame = *pFrame;
			pVideoState->videoPicture.pts = pts;

			SDL_LockMutex(pVideoState->pVideoFrameMutex);
			pVideoState->videoPicture.empty = false;
			SDL_UnlockMutex(pVideoState->pVideoFrameMutex);

之前视频的刷新延迟是写死的40ms,现在有了pts,就可以计算出真实的延迟了,计算方法分为下面几步:

1、把这帧pts和上一帧pts的时差当作下一帧pts和这帧pts的时差,这个时差就是刷新下一帧的延迟。

2、然后求出与音频时钟的时差,如果比音频时钟快了,我们就拉长这个延迟,让下一帧播慢点,反之缩短这个延迟,让下一帧播快点。音频时钟由一个专门的变量audioClock记录,并通过getAudioClock()计算出它的动态值。

3、由于定时任务和函数执行难免会有时间损耗,忽略掉这些时间损耗,实际的延迟还要再小一点点。因此我们还要用到系统时间,取开始播放时的系统时间为坐标零点,不断累加播放延迟,每次播放一帧前先把累加值减去系统当前时间,就能得到真实延迟了。这一段可能会看得有些云里雾里,建议仔细看代码,对着画一画时间轴就好理解了。

代码语言:javascript复制
void videoRefreshTimer(void* userdata)
{
    VideoState* pVideoState = (VideoState*)userdata;
    VideoPicture* pVideoPicture = &pVideoState->videoPicture;
    AVFrame* pFrame = &pVideoPicture->frame;
    double actualDelay, delay, syncThreshold, refClock, diff;

    if (!pVideoState) {
        scheduleRefresh(pVideoState, 100);
        return;
    }
    if (pVideoState->videoPicture.empty) {
        scheduleRefresh(pVideoState, 1);
        return;
    }

    //用这次pts与上一次pts的差值预测delay
    delay = pVideoPicture->pts - pVideoState->frameLastPts;
    if (delay <= 0 || delay >= 1) {
        //delay不合理,延用上次的delay
        delay = pVideoState->frameLastDelay;
    }
    
    pVideoState->frameLastDelay = delay;
    pVideoState->frameLastPts = pVideoPicture->pts;

    refClock = getAudioClock(pVideoState);
    diff = pVideoPicture->pts - refClock;

    //如果比音频慢的时间超过阈值,就立刻播放下一帧,相反情况则延迟两倍时间
    syncThreshold = delay > kAVSyncThreshold ? delay : kAVSyncThreshold;
    if (fabs(diff) < kAVNonSyncThreshold) {
        if (diff <= -syncThreshold) {
            delay = 0;
        }
        else if (diff >= syncThreshold) {
            delay *= 2;
        }
    }

    pVideoState->frameTimer  = delay;
    actualDelay = pVideoState->frameTimer - av_gettime() / 1000000.0;
    if (actualDelay < 0.01) {
        //这里应该直接跳过该帧,而不是0.01秒后渲染
        actualDelay = 0.01;
    }

	//cout << "actual delay " << actualDelay << endl;
    scheduleRefresh(pVideoState, actualDelay * 1000   0.5);

    //将YUV更新至texture,然后渲染
    SDL_UpdateYUVTexture(pVideoState->pTexture, NULL,
        pFrame->data[0], pFrame->linesize[0],
        pFrame->data[1], pFrame->linesize[1],
        pFrame->data[2], pFrame->linesize[2]);
    SDL_RenderClear(pVideoState->pRenderer);
    SDL_RenderCopy(pVideoState->pRenderer, pVideoState->pTexture, NULL, NULL);
    SDL_RenderPresent(pVideoState->pRenderer);

    SDL_LockMutex(pVideoState->pVideoFrameMutex);
    pVideoState->videoPicture.empty = true;
    SDL_CondSignal(pVideoState->pVideoFrameCond);
    SDL_UnlockMutex(pVideoState->pVideoFrameMutex);
}

代码里还有几处细节:第一,如果计算出的延迟异常,即过大或过小时,我们沿用上一次计算出的延迟。第二,维护一个实时的同步阈值,当音视频时差超过该阈值时,就说明该调整视频播放速度了。最后,给每一帧的刷新延迟预设最小值0.01s,实际上如果延迟小于0.01s,应该直接跳过该帧,不过我们先保留这个做法。

最后别忘了去初始化frameTimer和frameLastDelay。

代码语言:javascript复制
        pVideoState->frameTimer = (double)av_gettime() / 1000000.0;
        pVideoState->frameLastDelay = 40e-3;

Coding:音频时钟

现在来计算音频时钟。首先我们直接用音频包pts来更新音频时钟,但由于音频包里有多个音频帧,该音频包里的pts只是第一个音频帧的pts,因此我们还需要根据当前音频包播放了多少数据来算出实时的音频时钟。

在audioDecodeFrame()里需要新增两处代码,第一处是拿到音频pts后直接更新audioClock即可,第二处是根据采样率和样本数计算出该音频包播放结束时的pts,这样我们就拿到该音频包播放的pts区间了。

代码语言:javascript复制
int audioDecodeFrame(VideoState* pVideoState, uint8_t* audioBuf, int audioBufSize) {
    AVPacket* packet = &pVideoState->audioPacket;
    AVFrame* frame = &pVideoState->audioFrame;
    int len = -1;

    if (pVideoState->quit || pVideoState->audioQueue.pop(packet, 1) < 0) {
        return -1;
    }

    //该音频包开始播放时的pts
    pVideoState->audioClock = av_q2d(pVideoState->pAudioStream->time_base) * packet->pts;

    if (avcodec_send_packet(pVideoState->pAudioCodecCtx, packet)) {
        cerr << "avcodec_send_packet failed" << endl;
    }
    if (avcodec_receive_frame(pVideoState->pAudioCodecCtx, frame) == 0) {
        uint8_t* outBuf = pVideoState->outAudioBuf;
        int samples = swr_convert(pVideoState->pSwrCtx, &outBuf, kMaxAudioFrameSize, (const uint8_t**)frame->data, frame->nb_samples);
        len = av_samples_get_buffer_size(nullptr, frame->channels, samples, AV_SAMPLE_FMT_S16, 1);
        memcpy(audioBuf, frame->data[0], len);

        //计算出该音频包播放结束时的pts
        int bytesPerSamples = sizeof(int16_t) * pVideoState->pAudioCodecCtx->channels;
        pVideoState->audioClock  = (double)samples / (double)(bytesPerSamples * pVideoState->pAudioCodecCtx->sample_rate);
    }
    av_packet_unref(packet);
    return len;
}

有了pts区间后,我们就能根据缓存里还剩多少音频数据没播,计算出实时的音频时钟。

代码语言:javascript复制
double getAudioClock(VideoState* pVideoState)
{
    double pts;
    int restBufSize, bytesPerSec, bytesPerSamples;

    pts = pVideoState->audioClock;
    restBufSize = pVideoState->audioBufSize - pVideoState->audioBufIndex;
    bytesPerSec = 0;
    bytesPerSamples = sizeof(int16_t) * pVideoState->pVideoCodecCtx->channels;
    if (pVideoState->pAudioCodecCtx) {
        bytesPerSec = pVideoState->pAudioCodecCtx->sample_rate * bytesPerSamples;
    }
    if (bytesPerSec) {
        pts -= (double)restBufSize / bytesPerSec;
    }

    return pts;
}

以上就是视频同步的所有内容。下一章将实现音频同步。

代码:https://github.com/onlyandonly/ffmpeg_sdl_player

0 人点赞