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 ```

相關文章
相關標籤/搜索