FFmpeg 入门(5):视频同步

本文转自:FFmpeg 入门(5):视频同步 | www.samirchen.comgit

视频如何同步

在以前的教程中,咱们已经能够开始播放视频了,也已经能够开始播放音频了,可是视频和音频的播放还未同步,咱们要怎么办呢?github

PTS 和 DTS

好在音频和视频都有信息来控制播放时的速度和时机。音频流有一个采样率(sample rate),视频流有一个帧率(frame per second)。可是,若是咱们只是简单地经过数帧和乘上帧率来同步视频,那么它可能会和音频不一样步。实际上咱们将使用 PTS 和 DTS 信息来作音视频同步相关的事情。网络

在介绍 PTS 和 DTS 的概念前,先来了解一下 I、P、B 帧的概念。视频的播放过程能够简单理解为一帧一帧的画面按照时间顺序呈现出来的过程,就像在一个本子的每一页画上画,而后快速翻动的感受。可是在实际应用中,并非每一帧都是完整的画面,由于若是每一帧画面都是完整的图片,那么一个视频的体积就会很大,这样对于网络传输或者视频数据存储来讲成本过高,因此一般会对视频流中的一部分画面进行压缩(编码)处理。因为压缩处理的方式不一样,视频中的画面帧就分为了避免同的类别,其中包括:I 帧、P 帧、B 帧。数据结构

I 帧、P 帧、B 帧的区别在于:ide

  • I 帧(Intra coded frames):I 帧图像采用帧内编码方式,即只利用了单帧图像内的空间相关性,而没有利用时间相关性。I 帧使用帧内压缩,不使用运动补偿,因为 I 帧不依赖其它帧,因此是随机存取的入点,同时是解码的基准帧。I 帧主要用于接收机的初始化和信道的获取,以及节目的切换和插入,I 帧图像的压缩倍数相对较低。I 帧图像是周期性出如今图像序列中的,出现频率可由编码器选择。
  • P 帧(Predicted frames):P 帧和 B 帧图像采用帧间编码方式,即同时利用了空间和时间上的相关性。P 帧图像只采用前向时间预测,能够提升压缩效率和图像质量。P 帧图像中能够包含帧内编码的部分,即 P 帧中的每个宏块能够是前向预测,也能够是帧内编码。
  • B 帧(Bi-directional predicted frames):B 帧图像采用双向时间预测,能够大大提升压缩倍数。值得注意的是,因为 B 帧图像采用了将来帧做为参考,所以 MPEG-2 编码码流中图像帧的传输顺序和显示顺序是不一样的。

也就是说,一个 I 帧能够不依赖其余帧就解码出一幅完整的图像,而 P 帧、B 帧不行。P 帧须要依赖视频流中排在它前面的帧才能解码出图像。B 帧则须要依赖视频流中排在它前面或后面的帧才能解码出图像。这也解释了为何当咱们调用 avcodec_decode_video2() 函数后咱们不必定能获得一个完成解码的帧。函数

这就带来一个问题:在视频流中,先到来的 B 帧没法当即解码,须要等待它依赖的后面的 I、P 帧先解码完成,这样一来播放时间与解码时间不一致了,顺序打乱了,那这些帧该如何播放呢?这时就须要 DTS 和 PTS 信息了。ui

DTS、PTS 的概念以下所述:this

  • DTS(Decoding Time Stamp):即解码时间戳,这个时间戳的意义在于告诉播放器该在何时解码这一帧的数据。
  • PTS(Presentation Time Stamp):即显示时间戳,这个时间戳用来告诉播放器该在何时显示这一帧的数据。

须要注意的是:虽然 DTS、PTS 是用于指导播放端的行为,但它们是在编码的时候由编码器生成的。编码

当视频流中没有 B 帧时,一般 DTS 和 PTS 的顺序是一致的。但若是有 B 帧时,就回到了咱们前面说的问题:解码顺序和播放顺序不一致了。指针

好比一个视频中,帧的显示顺序是:I B B P,如今咱们须要在解码 B 帧时知道 P 帧中信息,所以这几帧在视频流中的顺序多是:I P B B,这时候就体现出每帧都有 DTS 和 PTS 的做用了。DTS 告诉咱们该按什么顺序解码这几帧图像,PTS 告诉咱们该按什么顺序显示这几帧图像。顺序大概以下:

PTS: 1 4 2 3
   DTS: 1 2 3 4
Stream: I P B B

当咱们在程序中调用 av_read_frame() 函数获得一个 packet 后,它会包含 PTS 和 DTS 信息。可是咱们真正想要的是最新解码好的原始帧的 PTS,这个咱们才知道何时显示这一帧。

同步

如今,假设咱们如今要显示某一帧视频,咱们具体怎么操做呢?如今有一个方案:当咱们显示完一帧,咱们须要计算何时显示下一帧。而后咱们设置一个新的定时来在这以后刷新视频。如你所想,咱们经过检查下一帧的 PTS 来决定这里的定时是多久。这个方案几乎是可行的,但有两个须要解决的问题:

第一个问题是怎么知道下一帧的 PTS。你可能会想,就在当前的帧的 PTS 上加一个根据视频帧率计算出的时间增量,可是有些视频须要重复帧,那就意味着这种状况下须要重复显示一帧屡次,这时候这里说的这个策略就会致使咱们提早显示了下一帧。因此咱们得考虑一下。

第二个问题是在一切都完美的状况下,音视频都按照正确的节奏播放,这时候咱们不会有同步的问题。可是事实上,用户的设备、网络,甚至视频文件都是有可能出现问题的,这时候咱们可能要作出选择了:音频同步视频时间、视频同步音频时间、音频和视频同步外部时钟。咱们的选择是视频同步音频时间

获取帧的 PTS

如今咱们把上面的策略实现到代码里。咱们须要在 VideoState 结构体中再增长一些成员。咱们再来看看 video thread,这里是咱们从队列获取 packets 的地方,这些 packets 是 decode thread 放入的。咱们要作的是当调用 avcodec_decode_video2 得到 frame 时,计算 PTS 数据。

AVFrame *pFrame;
double pts;

pFrame = av_frame_alloc();

for (;;) {
    if (packet_queue_get(&is->videoq, packet, 1) < 0) {
        // Means we quit getting packets.
        break;
    }
    pts = 0;
    
    // Save global pts to be stored in pFrame in first call.
    global_video_pkt_pts = packet->pts;
    // Decode video frame.
    avcodec_decode_video2(is->video_st->codec, pFrame, &frameFinished, packet);
    if (packet->dts == AV_NOPTS_VALUE && pFrame->opaque && *(uint64_t*)pFrame->opaque != AV_NOPTS_VALUE) {
        pts = *(uint64_t *)pFrame->opaque;
    } else if (packet->dts != AV_NOPTS_VALUE) {
        pts = packet->dts;
    } else {
        pts = 0;
    }
    pts *= av_q2d(is->video_st->time_base);

    // ... code ...

}

当咱们没法计算 PTS 时就设置它为 0。

一个须要注意的地方,咱们在这里使用了 int64 来存储 PTS,这是由于 PTS 是一个整型值。好比,若是一个视频流的帧率是 24,那么 PTS 为 42 则表示这一帧应该是第 42 帧若是咱们 1/24 秒播一帧的话。咱们能够用这个值除以帧率来获得以秒为单位的时间。视频流的 time_base 值则是 1/framerate,因此当咱们得到 PTS 后,咱们要乘上 time_base

用 PTS 来同步

如今 PTS 值已经被算出来了,那么接下来咱们来处理上面说到的两个同步问题。咱们将定义一个函数 synchronize_video() 来用于更新须要同步的视频帧的 PTS。这个函数同时也会处理没有得到 PTS 的状况。同时,咱们还要跟踪什么时候须要下一帧以便于咱们设置合理的刷新率。咱们可使用一个内置的 video_clock 变量来跟踪视频已经播过的时间。咱们把这个变量加到了 VideoState 中。

typedef struct VideoState {
    // ... code ...
    double video_clock; // pts of last decoded frame / predicted pts of next decoded frame.
    // ... code ...
}

double synchronize_video(VideoState *is, AVFrame *src_frame, double pts) {
    double frame_delay;
    
    if (pts != 0) {
        // If we have pts, set video clock to it.
        is->video_clock = pts;
    } else {
        // If we aren't given a pts, set it to the clock.
        pts = is->video_clock;
    }
    // Update the video clock.
    frame_delay = av_q2d(is->video_st->codec->time_base);
    // If we are repeating a frame, adjust clock accordingly.
    frame_delay += src_frame->repeat_pict * (frame_delay * 0.5);
    is->video_clock += frame_delay;
    return pts;
}

你能够看到这个函数也同时处理了帧重复的状况。

接下来,咱们给 queue_picture 加了个 pts 参数,在调用 synchronize_video 获取同步的 PTS 后,把这个值传入:

// Did we get a video frame?
if (frameFinished) {
    pts = synchronize_video(is, pFrame, pts);
    if (queue_picture(is, pFrame, pts) < 0) {
        break;
    }
}

同时咱们还更新了 VideoPicture 这个数据结构,添加了 pts 成员:

typedef struct VideoPicture {
    // ... code ...
    double pts;
} VideoPicture;

这样在 queue_picture 这里的变化即增长了一行保存 pts 值到 VideoPicture 的代码:

int queue_picture(VideoState *is, AVFrame *pFrame, double pts) {

    // ... code ...

    if (vp->bmp) {
        // ... covert picture ...

        vp->pts = pts;

        // ... alert queue ...
    }

    return 0;
}

因此如今咱们的 picture queue 中等待显示的图像都是有着合适的 PTS 值的了。如今让咱们来看看 video_refresh_timer() 这个用了刷新视频显式的函数。在上一节咱们简单的设置了一下刷新时间间隔为 80ms,如今咱们要根据 PTS 来计算它。

void video_refresh_timer(void *userdata) {
    VideoState *is = (VideoState *) userdata;
    VideoPicture *vp;
    double actual_delay, delay, sync_threshold, ref_clock, diff;
    
    if (is->video_st) {
        if (is->pictq_size == 0) {
            schedule_refresh(is, 1);
        } else {
            vp = &is->pictq[is->pictq_rindex];
            
            delay = vp->pts - is->frame_last_pts; // The pts from last time.
            if (delay <= 0 || delay >= 1.0) {
                // If incorrect delay, use previous one.
                delay = is->frame_last_delay;
            }
            // Save for next time.
            is->frame_last_delay = delay;
            is->frame_last_pts = vp->pts;
            
            // Update delay to sync to audio.
            ref_clock = get_audio_clock(is);
            diff = vp->pts - ref_clock;
            
            // Skip or repeat the frame. Take delay into account FFPlay still doesn't "know if this is the best guess."
            sync_threshold = (delay > AV_SYNC_THRESHOLD) ? delay : AV_SYNC_THRESHOLD;
            if (fabs(diff) < AV_NOSYNC_THRESHOLD) {
                if (diff <= -sync_threshold) {
                    delay = 0;
                } else if (diff >= sync_threshold) {
                    delay = 2 * delay;
                }
            }
            is->frame_timer += delay;
            // Computer the REAL delay.
            actual_delay = is->frame_timer - (av_gettime() / 1000000.0);
            if (actual_delay < 0.010) {
                // Really it should skip the picture instead.
                actual_delay = 0.010;
            }
            schedule_refresh(is, (int)(actual_delay * 1000 + 0.5));
            // Show the picture!
            video_display(is);
            
            // Update queue for next picture!
            if (++is->pictq_rindex == VIDEO_PICTURE_QUEUE_SIZE) {
                is->pictq_rindex = 0;
            }
            SDL_LockMutex(is->pictq_mutex);
            is->pictq_size--;
            SDL_CondSignal(is->pictq_cond);
            SDL_UnlockMutex(is->pictq_mutex);
        }
    } else {
        schedule_refresh(is, 100);
    }
}

咱们的策略是经过比较前一个 PTS 和当前的 PTS 来预测下一帧的 PTS。与此同时,咱们须要同步视频到音频。咱们将建立一个 audio clock 做为内部变量来跟踪音频如今播放的时间点,video thread 将用这个值来计算和判断视频是播快了仍是播慢了。

如今假设咱们有一个 get_audio_clock 函数来返回咱们 audio clock,那当咱们拿到这个值,咱们怎么去处理音视频不一样步的状况呢?若是只是简单的尝试跳到正确的 packet 来解决并非一个很好的方案。咱们要作的是调整下一次刷新的时机:若是视频播慢了咱们就加快刷新,若是视频播快了咱们就减慢刷新。既然咱们调整好了刷新时间,接下来用 frame_timer 跟电脑的时钟作一下比较。frame_timer 会一直累加在播放过程当中咱们计算的延时。换而言之,这个 frame_timer 就是播放下一帧的应该对上的时间点。咱们简单的在 frame_timer 上累加新计算的 delay,而后和电脑的时钟比较,并用获得的值来做为时间间隔去刷新。这段逻辑须要好好阅读一下下面的代码:

void video_refresh_timer(void *userdata) {
    VideoState *is = (VideoState *) userdata;
    VideoPicture *vp;
    double actual_delay, delay, sync_threshold, ref_clock, diff;
    
    if (is->video_st) {
        if (is->pictq_size == 0) {
            schedule_refresh(is, 1);
        } else {
            vp = &is->pictq[is->pictq_rindex];
            
            delay = vp->pts - is->frame_last_pts; // The pts from last time.
            if (delay <= 0 || delay >= 1.0) {
                // If incorrect delay, use previous one.
                delay = is->frame_last_delay;
            }
            // Save for next time.
            is->frame_last_delay = delay;
            is->frame_last_pts = vp->pts;
            
            // Update delay to sync to audio.
            ref_clock = get_audio_clock(is);
            diff = vp->pts - ref_clock;
            
            // Skip or repeat the frame. Take delay into account FFPlay still doesn't "know if this is the best guess."
            sync_threshold = (delay > AV_SYNC_THRESHOLD) ? delay : AV_SYNC_THRESHOLD;
            if (fabs(diff) < AV_NOSYNC_THRESHOLD) {
                if (diff <= -sync_threshold) {
                    delay = 0;
                } else if (diff >= sync_threshold) {
                    delay = 2 * delay;
                }
            }
            is->frame_timer += delay;
            // Computer the REAL delay.
            actual_delay = is->frame_timer - (av_gettime() / 1000000.0);
            if (actual_delay < 0.010) {
                // Really it should skip the picture instead.
                actual_delay = 0.010;
            }
            schedule_refresh(is, (int)(actual_delay * 1000 + 0.5));
            // Show the picture!
            video_display(is);
            
            // Update queue for next picture!
            if (++is->pictq_rindex == VIDEO_PICTURE_QUEUE_SIZE) {
                is->pictq_rindex = 0;
            }
            SDL_LockMutex(is->pictq_mutex);
            is->pictq_size--;
            SDL_CondSignal(is->pictq_cond);
            SDL_UnlockMutex(is->pictq_mutex);
        }
    } else {
        schedule_refresh(is, 100);
    }
}

有一些须要注意的点:首先,要确保前一个 PTS 以及当前 PTS 和前一个 PTS 间的 delay 是有效值,若是不是,那么咱们就用上一个 delay 值。其次,要有一个同步时间戳的阈值,由于咱们不可能完美的作到同步,FFPlay 中是用 0.01 做为这个阈值的,咱们还要确保这个阈值不要大于两个 PTS 的差值。最后,咱们设置最小的刷新时间为 10ms。

咱们在 VideoState 里加了很多成员,注意检查一下。另外,不要忘了在 stream_component_open() 初始化 frame_timerframe_last_delay

is->frame_timer = (double) av_gettime() / 1000000.0;
is->frame_last_delay = 40e-3;

音频时钟

如今是时候来实现音频时钟了。咱们能够在 audio_decode_frame() 中更新音频时钟,这里是音频解码的地方。要记住的是并非每次调用这个函数时都会处理一个新的 packet,因此有两个地方须要更新时钟:第一个地方是得到一个新的 packet 的时候,这时候设置音频时钟为 packet 的 PTS 便可;若是一个 packet 包含多个 frame 时,咱们就经过用播放的音频采样乘上采样率来跟踪音频播放的时间。

得到新 packet 的时候:

// If update, update the audio clock w/pts.
if (pkt->pts != AV_NOPTS_VALUE) {
    is->audio_clock = av_q2d(is->audio_st->time_base) * pkt->pts;
}
``
一个 packet 包含多个 frame 的时候:

pts = is->audio_clock;
pts_ptr = pts;
n = 2
is->audio_st->codec->channels;
is->audio_clock += (double) data_size / (double) (n * is->audio_st->codec->sample_rate);

一些细节:`audio_decode_frame` 函数添加了一个 `pts_ptr` 参数,它是一个指针,咱们用它来告知 `audio_callback()` 音频的 packet。这个会在后面同步音频和视频时起到做用。


最后咱们来实现 `get_audio_clock()` 函数。这里不是简单的得到 `is->audio_clock` 就好了,注意,咱们每次处理音频的时候都设置了它的 PTS,可是当你看 `audio_callback` 函数的实现时,你会发现它须要花费时间将全部的数据从音频的 packet 移到输出的 buffer 中,这就意味着咱们的 audio clock 的值可能会太领先,因此咱们要检查咱们差了多少时间。这里是代码:

double get_audio_clock(VideoState *is) {
double pts;
int hw_buf_size, bytes_per_sec, n;

pts = is->audio_clock; // Maintained in the audio thread.
hw_buf_size = is->audio_buf_size - is->audio_buf_index;
bytes_per_sec = 0;
n = is->audio_st->codec->channels * 2;
if (is->audio_st) {
    bytes_per_sec = is->audio_st->codec->sample_rate * n;
}
if (bytes_per_sec) {
    pts -= (double) hw_buf_size / bytes_per_sec;
}
return pts;

}

如今咱们应该能理解这里为何要这样写了。






以上即是咱们这节教程的所有内容,其中的完整代码你能够从这里得到:[https://github.com/samirchen/TestFFmpeg][6]

## 编译执行

你可使用下面的命令编译它:

$ gcc -o tutorial05 tutorial05.c -lavutil -lavformat -lavcodec -lswscale -lz -lm sdl-config --cflags --libs

找一个视频文件,你能够这样执行一下试试:

$ tutorial05 myvideofile.mp4 ```

相关文章
相关标签/搜索