Бесшовное разбиение и склейка видео с помощью DirectShow

в 12:17, , рубрики: Без рубрики

Один из наших отделов занимается ручным тестированием мультимедиа-компонентов для автомобилей. При этом постоянно ведется видеозапись всех производимых действий (нажатие кнопок, вставка дисков и т. п.) и реакции системы: одна из камер направлена на дисплей. Видео в данном случае является доказательством наблюдения ошибки, а также предоставляет разработчикам ценную информацию о том, какие действия производились и как быстро. Согласитесь, информация весьма важная для багрепортов, не так ли?

Специфика систем состоит в том, что ошибки могут возникнуть спонтанно и неожиданно, на любом этапе тесткейса, а то и вообще просто в режиме ожидания, когда запись видео не ведется. Интересующихся приглашаю под кат, где я опишу разработанное мной решение для бесшовного разбиения и склеивания видео. Благодаря нему запись ведется весь день, и видео сохраняется в удобные файлы небольшого размера, что позволяет нам отлавливать и документировать редчайшие ошибки, а заодно и радовать разработчиков десятками видео с невозможными реакциями системы.

Зачем разбивать видео?

В самом деле — зачем? Сохраняли бы себе восьмичасовые файлики, а потом в каком-нибудь видеоредакторе нарезали бы кусочки нужного видео, и дело с концом. На самом деле все так и происходило раньше, но тут много недостатков: копаться в длинных файлах неудобно, после вырезания так или иначе приходится перекомпрессировать видео с потерей времени и качества, да и место на дисках не резиновое все же. Кроме того, рано или поздно приходится останавливать запись для возможности работы с файлами, и вот тут-то по закону подлости всегда происходят самые вредные и неповторяющиеся ошибки. В общем, вердикт — надо резать на этапе записи!

Что нам предлагает DirectShow

Для реализации была выбрана технология DirectShow. Одно из требований также — на выходе должны получаться файлы Windows Media, а именно WMV3, так что было принято решение делать компрессию на лету, благо современные компьютеры это с легкостью позволяют. Основная идея такова: нам необходима возможность в произвольный момент времени переключить входящие потоки аудио и видео на другой файл, не потеряв при этом ни кадра. Так мы сможем вести запись в файлы продолжительностью, скажем, две минуты, а при необходимости бесшовно склеивать их.

Построим самый обычный граф фильтров для записи видео со звуком в формат Windows Media и с предпросмотром. Получится что-то вроде этого:
Бесшовное разбиение и склейка видео с помощью DirectShow

Как работает этот граф? Два входных фильтра для аудио и видео обслуживаются разными потоками, которые доставляют сэмплы (samples) на входные пины следующих фильтров. С помощью фильтра Smart Tee мы дублируем входящие видео-данные, одна копия отправляется на экран в Video Renderer, а вторая уходит в фильтр WM ASF Writer, который собственно и производит синхронизацию аудио и видео, их компрессию и запись в файл.

Решение «в лоб» с двумя фильтрами, которые можно было бы попеременно использовать в графе, изменяя имена выходных файлов, не работает.
Бесшовное разбиение и склейка видео с помощью DirectShow

У графа DirectShow есть одна особенность: до тех пор, пока он не будет остановлен, все его «выходные» фильтры держат файлы открытыми и не финализируют их. Кроме того, без остановки графа невозможно поменять имена выходных файлов или соединять/разъединять фильтры. Но остановка и запуск графа чреваты потерями нескольких кадров, а то и нескольких секунд! Ясно, что стандартными средствами не обойтись.

Самостоятельные графы

Одно из решений — сделать граф захвата аудио- и видео-данных (Capture Graph) независимым от графа записи (Record Graph), чтобы можно было останавливать последний для финализации файлов. Это возможно, например, с помощью GMFBridge от создателя DirectShow — Geraint Davies. Примерная схема работы всей системы выглядела бы так:
Бесшовное разбиение и склейка видео с помощью DirectShow

GMFBridge находится одновременно во всех трех графах, позволяя на лету переключать потоки сэмплов между первым и вторым Record Graph, не теряя ни одного сэмпла. В то время как один из графов записи коспрессирует наше видео, мы настраиваем второй граф (имя выходного файла), благо он вполне может быть остановлен, не влияя на остальные. В нужный момент мы запускаем второй граф, переключаем GMFBridge и останавливаем первый. Вуаля!

Но такое решение имеет очевидные недостатки. Во-первых, ресурсы. Необходимо иметь две копии графов записи, что отрицательно сказывается на общей производительности и использовании памяти. Кроме того, при наличии одновременно видео и аудио крайне сложно синхронизировать их — каждый граф имеет свой отсчет времени, к тому же сами сэмплы поставляются разными потоками. Все это приводило к спонтанным «зависаниям» самого GMFBridge в момент переключения графов, так что от этого решения было решено отказаться. Исходный код инструмента, конечно, открыт, и при желании можно было бы разобраться в причинах его нестабильной работы, но все же желание сэкономить ресурсы перевесило, и я решил подойти к задаче с другой стороны.

Пишем свой ASF Writer

С преферансом и куртизанками. Точно! Нам нужен такой WM ASF Writer, который умел бы по команде сам переключаться на другой файл без необходимости останавливать граф. Тогда мы сможем взять первый и самый простой граф, вставить туда наш кастомный фильтр вместо стандартного WM ASF Writer и радоваться жизни.
Создадим свой фильтр, добавив к стандартным методам еще один новый StreamToFile, который будет служить для переключения между файлами.

class CCustomASFWriter : public CBaseFilter
{
public:
	STDMETHOD(StreamToFile)(BSTR szFileName);
}

По поводу примеров кода

Все примеры кода ниже довольно сильно упрощены для наглядности, например, полностью выброшена обработка ошибок, убраны различные дополнительные проверки и т. п.

Чтобы не потерять сэмплы в момент переключения, а также чтобы не блокировать потоки доставки сэмплов, добавим в наш фильтр многопоточную очередь для входящих данных. Я использовал реализацию наподобие вот этой, немного допилив ее для использования в режиме multiple producers — single consumer. Очередь я решил использовать одну и для видео, и для аудио, и вот почему. Все упирается в нашу новую функцию переключения файлов. Для этого важно помнить, что частота поставки видео-сэмплов, как правило, много выше таковой у аудио: например, 30 Гц видео и 2 Гц (по 500 мс на сэмпл) у аудио. Соответственно, переключение нужно производить сразу после доставки аудио-сэмпла. Соблюдая естественный порядок сэмплов в очереди, можно очень удобно делать именно так.

Учитывая это, наш метод StreamToFile будет всего лишь сигнализировать фильтру, что он должен сразу после следующего аудио-сэмпла закрыть текущий файл и начать запись в новый. Пока подготавливается новый файл, все входящие сэмплы сохраняются в очереди.

HRESULT STDMETHODCALLTYPE CCustomASFWriter::StreamToFile(BSTR szFileName)
{
	wcscpy_s(m_szCurrentFile, szFileName);
	{
		CAutoLock lock(m_pLock);
		m_bSwitchRequested = TRUE;
	}
	return S_OK;
}

Собственно сама компрессия и запись в файлы происходит с использованием Windows Media Format SDK, а именно интерфейса IWMWriter.

	IWMWriter *pWriter = NULL;
	WMCreateWriter(NULL, &pWriter);

Для этого в отдельном потоке крутится цикл обработки входящих сэмплов:

	while (bRunning)
	{
		StreamSamplesToWriter(pWriter);
		DWORD dwWaitResult = WaitForSingleObject(hEventStopStreaming, 33);
		if (dwWaitResult == WAIT_OBJECT_0)
		{
			m_pPinVideo->StopQueuingNow();
			m_pPinAudio->StopQueuingNow();
			bRunning = FALSE;
		}
	}

Самое интересное и происходит в методе StreamSamplesToWriter. Здесь сэмплы отправляются в IWMWriter, а также происходит переключение файлов в правильный момент времени, если был дан сигнал к переключению с помощью метода StreamToFile.

STDMETHODIMP CCustomASFWriter::StreamSamplesToWriter(IWMWriter *pWriter)
{
	BOOL bMustSwitch = FALSE;
	void *pObject = NULL;

	while (m_pSamplesQueue->Pop(pObject))
	{
		CQueuedSample *pSample = (CQueuedSample*)pObject;	
		DWORD inputNumber = pSample->MediaType == MEDIATYPE_Video ? m_pPinVideo->InputNumber : m_pPinAudio->InputNumber;

		INSSBuffer *pBuffer = NULL;
		pWriter->AllocateSample(pSample->DataSize, &pBuffer);
		LPBYTE pbDestBuffer = NULL;
		pBuffer->GetBuffer(&pbDestBuffer);

		CopyMemory(pbDestBuffer, pSample->Data, pSample->DataSize);			
		pWriter->WriteSample(inputNumber, pSample->Start, pSample->IsDiscontinuity | pSample->IsSyncPoint, pBuffer);
		pBuffer->Release();
					
		if (inputNumber == m_pPinAudio->InputNumber)
		{
			{
				CAutoLock lock(m_pLock);
				bMustSwitch = m_bSwitchRequested;
				if (m_bSwitchRequested)
					m_bSwitchRequested = FALSE;
			}
			if (bMustSwitch)
			{				
				pWriter->EndWriting();				
				pWriter->SetOutputFilename(m_szCurrentFile);
				pWriter->BeginWriting();
			}
		}
		delete pSample;
	}
}

Итак, нам удалось добиться результата! Дергая метод StreamToFile в произвольные моменты времени, мы получаем новые файлы, причем не теряя ни единого кадра.

Склеиваем разрезанное

Ну что же, мы получили кучу файликов по две минуты. А что же делать, если нам нужно видео длиной 4 минуты, а самое интересное место наблюдается аккурат в момент переключения с одного файла на другой? Не беда — мы можем очень просто склеить эти файлы в один, причем сделать это без перекодирования! При этом склейка будет действительно бесшовной, так как при записи не было потеряно ни ни одного кадра.

Для этого используем IWMSyncReader и IWMWriterAdvanced.

	IWMWriter *pWriter = NULL;
	IWMWriterAdvanced *pWriterA = NULL;
	WMCreateWriter(NULL, &pWriter);
	pWriter->QueryInterface(IID_IWMWriterAdvanced, (void**)&pWriterA;
	IWMSyncReader *pReader = NULL;
	WMCreateSyncReader(NULL, 0, &pReader);

	for (element = m_oMergeFileList.begin(); element < m_oMergeFileList.end(); element ++)
	{
		pReader->Open(element->FileName);
		IWMProfile *pProfile = NULL;
		pReader->QueryInterface(IID_IWMProfile, (void**)&pProfile);
		
		// устанавливаем признак того, что мы не хотим декомпрессировать данные, а будем просто читать пакеты "как есть"
		for (WORD i = 0; i < dwStreamCount; i++)
		{
			pProfile->GetStream(i, &pStream);
			pStream->GetStreamNumber(&wStreamNumber);
			pReader->SetReadStreamSamples(wStreamNumber, TRUE);
		}

		HRESULT hr = S_OK;
		while (SUCCEEDED(hr))
		{
			hr = pReader->GetNextSample(0, &pSample, &cnsSampleTime, &cnsDuration, &dwFlags, &dwOutputNum, &wStreamNum);
			pWriterA->WriteStreamSample(wStreamNum, qwSampleTimeToWrite, 0, cnsDuration, dwFlags, pSample);
		}
	}

В итоге очень быстро (миллисекунды!) получаем файл длиной 4 минуты, место склейки в котором обнаружить невозможно. Строго говоря, это не совсем так, и при редких и определенных условиях склейка все же не получается настолько идеальной (например, при очень низкой частоте кадров, настроенной на камере). Однако в нормальных условиях при просмотре это место действительно незаметно.

Хочу еще резать и клеить!

На этом я не остановился и решил пойти дальше. Была добавлена волшебная кнопка, которая позволяет мгновенно получить файл с видео за последнюю минуту (на самом деле, желаемая длительность настраивается). Функция оказалась очень востребована тестерами — увидел неожиданную ошибку, ткнул на кнопку, получил видео.

Проиллюстрирую ситуацию, чтобы было более понятно. Предположим, кнопка нажимается почти сразу после того, как уже было произведено автоматическое переключение на следующий файл:
Бесшовное разбиение и склейка видео с помощью DirectShow
При нажатии на волшебную кнопку снова происходит переключение на новый файл, чтобы Файл 2 стал доступен. Но теперь нам нужно еще разрезать Файл 1, а потом склеить его с Файлом 2.

Здесь тоже ничего сложного. Про склеивание я уже писал, а разрезание производится аналогично: читаются сжатые пакеты без декомпрессии и пропускаются все ненужные, а начиная с некоторого момента времени все читаемые пакеты пишутся в файл с корректировкой временных меток, так чтобы первый пакет имел 00:00:00. Тут необходимо также правильно выбрать момент разрезания, чтобы первый пакет нового файла содержал опорный кадр (ключевой или I-кадр), а не предсказанный P-кадр (дельта-кадр). Опорные кадры могут размещаться в WMV-файлах даже раз в полминуты при небольших изменениях картинки, поэтому пришлось сконфигурировать использование форсированных опорных кадров. Я выбрал максимальную длительность между двумя опорными кадрами 1 с как компромисс между точностью позиционирования при нарезке и размером файла.

	IWMVideoMediaProps *pVMProps = NULL;
	pStreamConfig->QueryInterface(IID_IWMVideoMediaProps, (void**)&pVMProps);
	pVMProps->SetMaxKeyFrameSpacing(10000000i64);

Ура! Волшебная кнопка работает!

Заключение

В качестве заключения хочу сказать, что разбирался во всех тонкостях DirectShow самостоятельно — MSDN, примеры кода в интернетах и метод проб и ошибок. Но именно это и сделало результат таким приятным — приложение активно используется для записи видео, причем одновременно с нескольких камер. При обнаружении ошибок тестеры радостно тыкают на волшебную кнопку и спустя 2 секунды получают готовые и синхронизированные видео-файлы с трех камер без необходимости какого-либо видеомонтажа вообще! А ни что так не радует разработчика, как благодарности от пользователей, не так ли?:)

Автор: dymanoid

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js