程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> VC >> 關於VC++ >> VC實現卡拉OK字幕疊加

VC實現卡拉OK字幕疊加

編輯:關於VC++

一. 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的封裝類。這些類的繼承關系如下:

圖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對象也進行了類封裝。這些類的繼承關系如下:

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

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

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

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

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

CPalette——調色板的封裝類;

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

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

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

二. 實現原理

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

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

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

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

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

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

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

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

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

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

// 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);

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

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

三. 關鍵實現

我們使用VC生成一個基於對話框的程序來演示卡拉OK字幕疊加的實現。

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

BOOL CKaraokeDemoDlg::OnInitDialog()
{
CDialog::OnInitDialog();
// TODO: Add extra initialization here
mController.SetTargetWindow(&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 < currentChar; i++)
{
// 累加上當前進度中的字符以前的所有字符的寬度
sungLength += mFromToArray[i].size.cx;
}
}
else
{
// 如果無法定位到任何一個字符,則畫出整行
sungLength = mTotalWidth;
}
// 將字幕字體選入目標窗口的DC中
CFont * pOldFont = (CFont *) mClientDC->SelectObject(&mTextFont);
mClientDC->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->SelectClipRgn(&mSungRegion, RGN_COPY);
mClientDC->SetPolyFillMode(WINDING);
HPEN pOldPen = (HPEN) mClientDC->SelectObject(mSungBoundaryPen);
HBRUSH pOldBrush = (HBRUSH) mClientDC->SelectObject(mSungTextBrush);
mClientDC->BeginPath();
mClientDC->TextOut(mStartPoint.x, mStartPoint.y, mSubtitleLine);
mClientDC->EndPath();
mClientDC->StrokeAndFillPath(); // 畫出字符路徑並填充
mClientDC->SelectClipPath(RGN_AND);
// 恢復以前的畫筆和畫刷
mClientDC->SelectObject(pOldPen);
mClientDC->SelectObject(pOldBrush);
// 畫出第二部分:尚未唱過的字幕(黑色勾邊空心字)
pOldPen = (HPEN) mClientDC->SelectObject(mSingingBoundaryPen);
pOldBrush = (HBRUSH) mClientDC->SelectObject(mSingingTextBrush);
mClientDC->SelectClipRgn(&mSingingRegion, RGN_COPY);
mClientDC->BeginPath();
mClientDC->TextOut(mStartPoint.x, mStartPoint.y, mSubtitleLine);
mClientDC->EndPath();
mClientDC->StrokePath(); // 畫出字符路徑(不填充)
mClientDC->SelectClipPath(RGN_AND);
// 恢復以前的畫筆和畫刷
mClientDC->SelectObject(pOldBrush);
mClientDC->SelectObject(pOldPen);
mSungRegion.DeleteObject();
mSingingRegion.DeleteObject();
// 恢復目標窗口為“全區域”
RECT bounds;
mTargetWnd->GetClientRect(&bounds);
CRgn rgn;
rgn.CreateRectRgn(bounds.left, bounds.top, bounds.right, bounds.bottom);
ret = mClientDC->SelectClipRgn(&rgn, RGN_COPY);
// 恢復以前的字體
mClientDC->SelectObject(pOldFont);
// 如果無法定位到任何一個字符,則返回一個錯誤值
return (currentChar != -1);
}
// 根據當前播放到的時間點,定位到當前進度中的字符
int CSubtitleController::LocateChar(DWORD inStreamTime, DWORD & outTimeInChar)
{
// mCharCount為整個字幕行的字符個數
for (int i = 0; i < mCharCount; i++)
{
if (inStreamTime >= mFromToArray[i].from &&
inStreamTime < mFromToArray[i].to)
{
outTimeInChar = inStreamTime - mFromToArray[i].from;
return i;
}
}
return -1;
}

四. 性能優化

我們在演示中發現,頻繁地直接在窗口DC中畫圖會帶來一定的閃爍感。對此,我們可以進行一下優化,即首先創建一個與目標窗口DC兼容的內存DC,在這個內存DC中畫好字幕後,再將字幕位圖從內存DC拷貝到目標窗口DC中去。

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

BOOL CSubtitleController::DrawSubtitle2(DWORD inStreamTime)
{
ASSERT(mClientDC);
RECT bounds;
mTargetWnd->GetClientRect(&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->GetSafeHdc(),wndWidth,wndHeight);
// 將位圖選入內存DC
HBITMAP oldbmp = (HBITMAP) memDC.SelectObject(membmp);
FillRect(memDC.GetSafeHdc(), &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 < currentChar; i++)
{
sungLength += mFromToArray[i].size.cx;
}
}
else
{
sungLength = mTotalWidth;
}
CFont * pOldFont = (CFont *) memDC.SelectObject(&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(&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(&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->BitBlt(0, 0, wndWidth, wndHeight, &memDC, 0, 0, SRCCOPY);
// 刪除內存DC及使用的資源
memDC.SelectObject(oldbmp);
DeleteObject(membmp);
memDC.DeleteDC();
return (currentChar != -1);
}

五. 結束語

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

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved