天天看點

VC實作卡拉OK字幕疊加

一. GDI程式設計基礎

 字幕疊加,應當是屬于圖形、圖像處理的範疇。在Windows平台上,圖形、圖像處理的方法當然首選GDI(Graphics Device Interface,圖形裝置接口)。GDI是什麼?GDI其實是一套API函數;它們功能豐富,使用起來簡單、靈活。下面,我們首先來介紹一些GDI程式設計的基礎知識。

  GDI函數有很多,我們大緻可以把它們分成如下幾類:

  · 裝置上下文(Device Context,簡稱DC)函數,如GetDC、CreateDC、DeleteDC等;

  · 畫線函數,如LineTo、Polyline、Arc等;

  · 填充畫圖函數,如Ellipse、FillRect、Pie等;

  · 畫圖屬性函數,如SetBkColor、SetBkMode、SetTextColor等;

  · 文本、字型函數,如TextOut、GetTextExtentPoint32、GetFontData等;

  · 位圖函數,如SetPixel、BitBlt、StretchBlt等;

  · 坐标函數,如DPtoLP、LPtoDP、ScreenToClient、ClientToScreen等;

  · 映射函數,如SetMapMode、SetWindowExtEx、SetViewportExtEx等;

  · 元檔案(MetaFile)函數,如PlayMetaFile、SetWinMetaFileBits等;

  · 區域(Region)函數,如FillRgn、FrameRgn、InvertRgn等;

  · 路徑(Path)函數,如BeginPath、EndPath、StrokeAndFillPath等;

  · 裁剪(Clipping)函數,如SelectClipRgn、SelectClipPath等。

  上述這些函數可以完成繪制使用者界面中的各個部分,包括我們在Windows平台上司空見慣的視窗、菜單、工具條、按鈕等。除了完成顯示操作功能外,GDI還提供了一些繪圖對象,用以渲染顯示。這些GDI對象包括:

  裝置上下文(DC)——具有如顯示器或列印機等輸出裝置的繪圖屬性資訊的資料結構;

畫筆(Pen)——用于繪制線條;

  畫刷(Brush)——用于圖案的填充;

  字型(Font)——用于确定文本字元的樣式;

  位圖(Bitmap)——用于存儲圖像;

  調色闆(Palette)——螢幕上畫圖時可以使用的一些顔色的集合。

  DC在GDI中是一個非常重要的概念。在MSDN上檢視各個GDI函數的使用說明,我們會發現大部分GDI函數都有一個HDC類型的參數;HDC就是DC句柄。Windows應用程式進行圖形、圖像處理的一般操作步驟如下:

  1. 取得指定視窗的DC;

  2. 确定使用的坐标系及映射方式;

  3. 進行圖形、圖像或文字處理;

  4. 釋放所使用的DC。

  為了進一步簡化GDI函數的使用,或者說為了适應面向對象的程式設計風格,微軟的MFC類庫提供了幾個DC的封裝類。這些類的繼承關系如下:

VC實作卡拉OK字幕疊加

  圖1 關于DC的幾個MFC類的繼承關系

  我們知道,絕大部分MFC類都是從CObject類派生的,CDC類也不例外。我們看到,CDC類是最基本的DC封裝類;它幾乎對應封裝了所有的GDI函數。另外,CDC類的各個派生類各有專門的用途:

  CClientDC——在視窗的客戶區畫圖的DC;

  CMetaFileDC——用于操作Windows元檔案的DC;

  CPaintDC——響應WM_PAINT消息時畫圖使用的DC,多見于MFC程式的OnDraw函數中;

  CWindowDC——在整個視窗範圍(包括架構、工具條等)中畫圖的DC。

  MFC除了對DC進行類封裝外,對其它GDI對象也進行了類封裝。這些類的繼承關系如下:

VC實作卡拉OK字幕疊加

  圖2 GDI對象的MFC封裝類的繼承關系

  CGdiObject——GDI對象的父類,定義了GDI對象封裝類的一些公有函數接口;

  CBitmap——位圖相關操作的封裝類,包括位圖的裝入或建立等;

  CBrush——畫刷對象的封裝類;

  CFont——字型屬性及相關操作的封裝類;

  CPalette——調色闆的封裝類;

  CPen——畫筆對象的封裝類;

  CRgn——區域對象以及區域相關操作的封裝類。

  通過上述介紹,相信讀者對GDI程式設計有了一定的了解。接下去,我們就來讨論卡拉OK字幕疊加的實作原理。

  二. 實作原理

  字幕疊加,最基本的一種是在靜态圖像上進行的,一般就是直接在圖像上輸出标準的字元串,以合成新的圖像幀;而視訊上的字幕疊加,則是在連續的圖像幀序列上進行的,單幀上的疊加與靜态圖像上的疊加類似。本文所要講述的卡拉OK字幕疊加,就是一種在視訊上進行的字幕疊加。

 在視訊上進行疊加的字幕,一般可以呈現出多種動态效果,比如滾動、旋轉等;卡拉OK字幕需要表達更多的内容,它至少包括:

  1.根據進度,顯示不同的字幕内容(即歌詞);

  2.字幕上應該表達出卡拉OK的音樂節奏;

  3.對字幕進行勾邊或其他效果處理,以突出顯示。

  以下是卡拉OK字幕效果的示範圖:

 (圖檔較大,請拉動滾動條觀看)

VC實作卡拉OK字幕疊加

  (圖檔較大,請拉動滾動條觀看)

  圖3 卡拉OK字幕效果圖

  簡單的字幕疊加我們就可以通過GDI函數來實作。我們知道,字元的輸出可以使用TextOut函數;但是,如何輸出空心字,如何填充空心字呢?我們這裡要用到路徑。字元路徑的繪制過程參考如下:

<code>CClientDC * pClientDC = new CClientDC(mTargetWnd); // ...... pClientDC-&gt;BeginPath(); pClientDC-&gt;TextOut(x, y, szSubtitleLine); pClientDC-&gt;EndPath(); // pClientDC-&gt;StrokePath(); pClientDC-&gt;StrokeAndFillPath();</code>

  我們看到,在TextOut函數調用前後分别調用了BeginPath函數和EndPath函數,以記錄字元輸出的路徑(實際上就是字元的輪廓);然後調用StrokePath函數将路徑勾勒出來,或者調用 StrokeAndFillPath函數在勾勒路徑的同時進行填充。需要注意的是,路徑勾勒的顔色由DC中目前選入的畫筆決定,填充的顔色由DC中目前選入的畫刷決定。

  那麼,我們如何在字幕上表示演唱進度呢?根據音樂的節奏,我們需要為每個字元确定開始填充的時刻,并且指定該字元完成填充需要的時間。比如上述“真的好想你”一句歌詞,我們從時刻0開始填充,讓“真”顯示1500毫秒,“的”顯示300毫秒,“好”顯示1600毫秒, “想”顯示500毫秒,“你”顯示1000毫秒。于是,我們可以從開始播放時進行計時,并且以一定的頻率重新整理目前播放到的時間點;表現在卡拉OK字幕上,就是不斷地更新已經唱過的字幕和尚未唱過的字幕之間的分界線。從視覺效果上,我們看到的是填充色随着音樂從左到右地行進;并且單個字元的行進速度,也因該字元上配置設定的總的填充時間不同而不同,進而展現出應有的節奏感。

 另外,我們從上述卡拉OK字幕效果圖中不難看出,已經唱過的字幕和尚未唱過的字幕的畫法是不一樣的:前半部分是藍色填充、白色勾邊,後半部分是黑色勾邊的空心字。而且,這兩部分之間的分界線有可能位于某個字元中(不會總是剛好在相鄰字元的間隙中)。那麼,如何準确地畫出這兩部分字幕呢?我們這裡可以使用GDI的區域、路徑裁剪操作。首先,根據目前進度,将視窗分成左右兩個矩形區域:

<code>// xStart, yStart為字幕行第一個字元顯示的(x, y)坐标 // pregress為目前進度坐标(已經唱過的寬度) // sz為SIZE類型的變量,記錄整行字幕的寬、高 CRgn region1, region2; region1.CreateRectRgn(xStart, yStart, xStart + pregress, yStart + sz.cy); region2.CreateRectRgn(xStart + pregress, yStart, xStart + sz.cx, yStart + sz.cy);</code>

  在畫兩部分字幕的路徑之前,分别調用SelectClipRgn函數選入各自的區域;等到字幕路徑畫完之後,再調用SelectClipPath函數跟先前選入的區域進行“與”操作,即提取兩者的公共部分。整個過程參考如下:

<code>pClientDC-&gt;SelectClipRgn(&amp;region1, RGN_COPY); // 1.選入用于畫已經唱過字幕的畫筆、畫刷 // 2.畫字幕路徑 // ...... pClientDC-&gt;SelectClipPath(RGN_AND); pClientDC-&gt;SelectClipRgn(&amp;region2, RGN_COPY); // 1.選入用于畫尚未唱過字幕的畫筆、畫刷 // 2.畫字幕路徑 // ...... pClientDC-&gt;SelectClipPath(RGN_AND);</code>

  三. 關鍵實作

  我們使用VC生成一個基于對話框的程式來示範卡拉OK字幕疊加的實作。程式界面如下:

(圖檔較大,請拉動滾動條觀看)

  圖4 示範程式界面

  為了使字幕疊加的過程更加清晰,我們設計了一個邏輯控制類 CSubtitleController。在進行真正的字幕疊加之前,我們必須首先調用CSubtitleController類的 SetTargetWindow函數設定字幕的顯示視窗,随後調用SetSubtitleLine函數設定字幕行的内容、填充時間等屬性。具體實作中,我們在主對話框類CKaraokeDemoDlg中定義一個CSubtitleController類的執行個體mController,并且在對話框的初始化函數OnInitDialog中進行了如下的調用:

<code>BOOL CKaraokeDemoDlg::OnInitDialog() { CDialog::OnInitDialog(); // TODO: Add extra initialization here mController.SetTargetWindow(&amp;mKaraokeWnd); mController.SetSubtitleLine(mSubtitleArray, mDurationArray, 0, 5); // ...... return TRUE; }   其中,mKaraokeWnd表示字幕顯示視窗,是一個CStatic類的對象執行個體;mSubtitleArray是CString類型的數組,用于存儲字幕内容(注意,應将字幕行中的各個字元單獨存儲);mDurationArray是int類型的數組,用于存儲字幕行中各個字元填充需要的時間。 mSubtitleArray和mDurationArray可以在CKaraokeDemoDlg類的構造函數中做如下的初始化: mSubtitleArray = new CString[5]; mDurationArray = new int[5]; mSubtitleArray[0] = "真"; mSubtitleArray[1] = "的"; mSubtitleArray[2] = "好"; mSubtitleArray[3] = "想"; mSubtitleArray[4] = "你"; mDurationArray[0] = 1500; // 以毫秒為機關 mDurationArray[1] = 300; mDurationArray[2] = 1600; mDurationArray[3] = 500; mDurationArray[4] = 1000;   主對話框類中還使用了一個定時器,定時間隔是40毫秒,即以每秒25幀的頻率重新整理字幕疊加的進度。我們在開始播放(即當使用者按下“Play”按鈕)時記下系統時間(存儲到DWORD類型的變量mStartTime中),然後在每次定時到達的時候再次讀取系統時間,與mStartTime做內插補點運算,得到目前播放到的時間點(我們暫且稱之為流時間)。在定時器消息響應函數CKaraokeDemoDlg::OnTimer中,我們會調用 CSubtitleController類的DrawSubtitle函數來完成實際的卡拉OK字幕輸出,這個函數的參數就是這個流時間。   在CSubtitleController類中,我們看到DrawSubtitle函數的具體實作如下: BOOL CSubtitleController::DrawSubtitle(DWORD inStreamTime) { ASSERT(mClientDC); DWORD timeInChar = 0; // 相對于目前字元填充的開始時間的時間 LONG sungLength = 0; // 已經唱過的字幕寬度 // LocateChar為CSubtitleController類的一個私有函數 // 根據目前播放到的時間點,定位到目前進度中的字元, // 并且得到播放時間點在目前字元中的相對時間 int currentChar = LocateChar(inStreamTime, timeInChar); if (currentChar != -1) // 定位成功 { // 計算已經唱過的字幕寬度 // mFromToArray數組記錄各個字元的屬性,包括開始、結束時間、尺寸等 sungLength = mFromToArray[currentChar].size.cx * timeInChar; sungLength = sungLength / mFromToArray[currentChar].duration; for (int i = 0; i &lt; currentChar; i++) { // 累加上目前進度中的字元以前的所有字元的寬度 sungLength += mFromToArray[i].size.cx; } } else { // 如果無法定位到任何一個字元,則畫出整行 sungLength = mTotalWidth; } // 将字幕字型選入目标視窗的DC中 CFont * pOldFont = (CFont *) mClientDC-&gt;SelectObject(&amp;mTextFont); mClientDC-&gt;SetBkMode(TRANSPARENT); // 設定輸出時背景透明 // 生成已經唱過的和尚未唱過的兩塊視窗區域 // mSungRegion和mSingingRegion均是CRgn類對象執行個體 mSungRegion.CreateRectRgn(mStartPoint.x, mStartPoint.y, mStartPoint.x + sungLength, mStartPoint.y + mFromToArray[0].size.cy); mSingingRegion.CreateRectRgn(mStartPoint.x + sungLength, mStartPoint.y, mStartPoint.x + mTotalWidth, mStartPoint.y + mFromToArray[0].size.cy); // 畫出第一部分:已經唱過的字幕(藍色填充,白色勾邊) int ret = mClientDC-&gt;SelectClipRgn(&amp;mSungRegion, RGN_COPY); mClientDC-&gt;SetPolyFillMode(WINDING); HPEN pOldPen = (HPEN) mClientDC-&gt;SelectObject(mSungBoundaryPen); HBRUSH pOldBrush = (HBRUSH) mClientDC-&gt;SelectObject(mSungTextBrush); mClientDC-&gt;BeginPath(); mClientDC-&gt;TextOut(mStartPoint.x, mStartPoint.y, mSubtitleLine); mClientDC-&gt;EndPath(); mClientDC-&gt;StrokeAndFillPath(); // 畫出字元路徑并填充 mClientDC-&gt;SelectClipPath(RGN_AND); // 恢複以前的畫筆和畫刷 mClientDC-&gt;SelectObject(pOldPen); mClientDC-&gt;SelectObject(pOldBrush); // 畫出第二部分:尚未唱過的字幕(黑色勾邊空心字) pOldPen = (HPEN) mClientDC-&gt;SelectObject(mSingingBoundaryPen); pOldBrush = (HBRUSH) mClientDC-&gt;SelectObject(mSingingTextBrush); mClientDC-&gt;SelectClipRgn(&amp;mSingingRegion, RGN_COPY); mClientDC-&gt;BeginPath(); mClientDC-&gt;TextOut(mStartPoint.x, mStartPoint.y, mSubtitleLine); mClientDC-&gt;EndPath(); mClientDC-&gt;StrokePath(); // 畫出字元路徑(不填充) mClientDC-&gt;SelectClipPath(RGN_AND); // 恢複以前的畫筆和畫刷 mClientDC-&gt;SelectObject(pOldBrush); mClientDC-&gt;SelectObject(pOldPen); mSungRegion.DeleteObject(); mSingingRegion.DeleteObject(); // 恢複目标視窗為“全區域” RECT bounds; mTargetWnd-&gt;GetClientRect(&amp;bounds); CRgn rgn; rgn.CreateRectRgn(bounds.left, bounds.top, bounds.right, bounds.bottom); ret = mClientDC-&gt;SelectClipRgn(&amp;rgn, RGN_COPY); // 恢複以前的字型 mClientDC-&gt;SelectObject(pOldFont); // 如果無法定位到任何一個字元,則傳回一個錯誤值 return (currentChar != -1); } // 根據目前播放到的時間點,定位到目前進度中的字元 int CSubtitleController::LocateChar(DWORD inStreamTime, DWORD &amp; outTimeInChar) { // mCharCount為整個字幕行的字元個數 for (int i = 0; i &lt; mCharCount; i++) { if (inStreamTime &gt;= mFromToArray[i].from &amp;&amp; inStreamTime &lt; mFromToArray[i].to) { outTimeInChar = inStreamTime - mFromToArray[i].from; return i; } } return -1; }</code>

 四. 性能優化

  我們在示範中發現,頻繁地直接在視窗DC中畫圖會帶來一定的閃爍感。對此,我們可以進行一下優化,即首先建立一個與目标視窗DC相容的記憶體DC,在這個記憶體DC中畫好字幕後,再将字幕位圖從記憶體DC拷貝到目标視窗DC中去。

  我們可以參考CSubtitleController類的DrawSubtitle2函數的實作:

<code>BOOL CSubtitleController::DrawSubtitle2(DWORD inStreamTime) { ASSERT(mClientDC); RECT bounds; mTargetWnd-&gt;GetClientRect(&amp;bounds); int wndWidth = bounds.right - bounds.left; int wndHeight = bounds.bottom - bounds.top; CDC memDC; // 建立與目标視窗DC相容的記憶體DC memDC.CreateCompatibleDC(mClientDC); // 建立與目标視窗DC相容的位圖 HBITMAP membmp = CreateCompatibleBitmap(mClientDC-&gt;GetSafeHdc(),wndWidth,wndHeight); // 将位圖選入記憶體DC HBITMAP oldbmp = (HBITMAP) memDC.SelectObject(membmp); FillRect(memDC.GetSafeHdc(), &amp;bounds, (HBRUSH)GetStockObject(LTGRAY_BRUSH)); /*----------------- 以下字幕操作都在記憶體DC中進行 ----------------*/ DWORD timeInChar = 0; LONG sungLength = 0; int currentChar = LocateChar(inStreamTime, timeInChar); if (currentChar != -1) { sungLength = mFromToArray[currentChar].size.cx * timeInChar; sungLength = sungLength / mFromToArray[currentChar].duration; for (int i = 0; i &lt; currentChar; i++) { sungLength += mFromToArray[i].size.cx; } } else { sungLength = mTotalWidth; } CFont * pOldFont = (CFont *) memDC.SelectObject(&amp;mTextFont); memDC.SetBkMode(TRANSPARENT); mSungRegion.CreateRectRgn(mStartPoint.x, mStartPoint.y, mStartPoint.x + sungLength, mStartPoint.y + mFromToArray[0].size.cy); mSingingRegion.CreateRectRgn(mStartPoint.x + sungLength, mStartPoint.y, mStartPoint.x + mTotalWidth, mStartPoint.y + mFromToArray[0].size.cy); // Draw the first part which has been sung int ret = memDC.SelectClipRgn(&amp;mSungRegion, RGN_COPY); memDC.SetPolyFillMode(WINDING); HPEN pOldPen = (HPEN) memDC.SelectObject(mSungBoundaryPen); HBRUSH pOldBrush = (HBRUSH) memDC.SelectObject(mSungTextBrush); memDC.BeginPath(); memDC.TextOut(mStartPoint.x, mStartPoint.y, mSubtitleLine); memDC.EndPath(); memDC.StrokeAndFillPath(); memDC.SelectClipPath(RGN_AND); memDC.SelectObject(pOldPen); memDC.SelectObject(pOldBrush); // Draw the second part which is waiting for being sung pOldPen = (HPEN) memDC.SelectObject(mSingingBoundaryPen); pOldBrush = (HBRUSH) memDC.SelectObject(mSingingTextBrush); memDC.SelectClipRgn(&amp;mSingingRegion, RGN_COPY); memDC.BeginPath(); memDC.TextOut(mStartPoint.x, mStartPoint.y, mSubtitleLine); memDC.EndPath(); memDC.StrokePath(); memDC.SelectClipPath(RGN_AND); memDC.SelectObject(pOldBrush); memDC.SelectObject(pOldPen); mSungRegion.DeleteObject(); mSingingRegion.DeleteObject(); memDC.SelectObject(pOldFont); // 将記憶體DC中的位圖拷貝到目标視窗DC中 mClientDC-&gt;BitBlt(0, 0, wndWidth, wndHeight, &amp;memDC, 0, 0, SRCCOPY); // 删除記憶體DC及使用的資源 memDC.SelectObject(oldbmp); DeleteObject(membmp); memDC.DeleteDC(); return (currentChar != -1); }</code>

  五. 結束語

  本文介紹了卡拉OK字幕疊加的一般原理以及VC上使用GDI的一種簡單實作,并且提供了完整的示例源代碼,希望能夠對讀者朋友們有所啟示。

繼續閱讀