Прошлая статья по программе была нацелена на освещение проблемы (если это вообще можно так назвать), тут же я буду писать про «внутренности» программы. Поехали.

Если коротко: своей логики в отдельных DLL у HomRec нет — раньше захват экрана, кодирование и таймер жили в трёх отдельных библиотеках (hr_dxgi_capture.dll, hr_encoder_helpers.dll, hr_stopwatch.dll), которые hr.exe подгружал через LoadLibrary в рантайме. Сейчас всё это компилируется прямо в hr.exe. А вот рядом с hr.exe в папке всё равно лежит пачка DLL — это не забытый мусор, а осознанное решение: GUI‑фреймворк wxWidgets собирается динамически (флаг -DWXUSINGDLL в Makefile), а через него подтягиваются библиотеки декодирования картинок (libjpeg, libpng, libtiff, libwebp и так далее) и рантайм MinGW. Так что «единый бинарник» — это про архитектуру самого HomRec, а не про то, что в папке с программой вообще нет DLL.

Как экран вообще попадает в программу

На Windows для захвата экрана есть два классических пути: GDI (медленно и печально) и DXGI Desktop Duplication API. HomRec использует второй вариант.

Смысл DXGI Desktop Duplication в том, что видеокарта сама отдаёт вам текстуру рабочего стола — не нужно постранично вычитывать пиксели через GDI. Вызов AcquireNextFrame возвращает GPU‑текстуру с очередным кадром, а если на экране ничего не изменилось — честно говорит «нечего отдавать» (таймаут), и можно просто повторно использовать прошлый кадр вместо лишней работы.

Проблема в том, что эта GPU‑текстура недоступна напрямую из CPU‑памяти — её сначала нужно скопировать в «staging‑текстуру» и замапить. А вот тут прячется первая интересная деталь.

Трюк с двойной буферизацией, который спас видео от фризов

Наивная реализация выглядит так: получили кадр → скопировали в staging → замапили → скопировали в свой буфер → отпустили кадр. Всё последовательно, один за другим.

HomRec делает иначе — держит две staging‑текстуры и работает со сдвигом на один кадр: на кадре N копируем GPU‑текстуру в staging[write_idx] и сразу отпускаем DXGI‑кадр (не дожидаясь Map/Unmap) — это позволяет системе быстрее выдать следующий кадр; наружу при этом отдаём не только что скопированный кадр, а предыдущий, уже отлежавшийся staging[pending_idx], у которого GPU‑копирование почти наверняка успело завершиться. То есть капча всегда работает «на кадр назад» — и это не баг, а осознанный пайплайнинг: пока читается один буфер, в другой пишется следующий.

Картинка-пояснение за "Трюк с двойной буферизацией"
Картинка‑пояснение за «Трюк с двойной буферизацией»

Отдельная история — почему у чтения (Map) большой бюджет ожидания (250 мс), в отличие от короткого таймаута AcquireNextFrame (доли одного кадра). Изначально оба использовали один и тот же короткий таймаут, и это давало жуткий баг: при записи тяжёлой 3D‑игры на нагруженной GPU видео периодически замирало на несколько секунд, а звук уезжал — причём именно звук убегал вперёд, потому что он пишется по своим отдельным часам WASAPI и не зависит от загрузки видеокарты. Причина была в том, что Map() от перегруженной GPU не укладывался в те же 8–33 мс, что и таймаут захвата, и код воспринимал это как «кадр не готов», повторяя старый кадр — вместо того чтобы просто чуть подождать реального копирования. Разделили таймауты — проблема ушла.

BGRA → YUV420p: почему это не делает GPU

Кадр с экрана приходит в формате BGRA. Кодеку нужен YUV420p. Конвертацию можно было бы отдать GPU или библиотеке, но в HomRec она сделана руками, на CPU, и разбита на полосы (band) по высоте кадра, каждая полоса летит отдельному worker‑потоку из небольшого пула.

Расчёт простой: цель программы — работать на слабом или встроенном видеоядре, где лишняя нагрузка на GPU может быть дороже, чем несколько лишних процентов CPU у современного многоядерного процессора. Плюс это убирает зависимость от версии GPU‑драйвера и лишнего шейдерного кода — на старом ноутбуке из первой статьи (Intel UHD, интегрированная графика без своей видеопамяти) это осмысленный компромисс.

Как кадры доезжают до ffmpeg: труба, а не временный файл

Дальше готовый YUV‑кадр нужно отдать ffmpeg на кодирование. Вариант «писать во временный файл и скармливать его ffmpeg» отпал сразу — лишний диск, лишняя задержка. HomRec пишет сырые YUV‑кадры прямо в stdin запущенного процесса ffmpeg (-f rawvideo -pixel_format yuv420p -i pipe:0)

Пояснение к пути кадров до ffmpeg'а
Пояснение к пути кадров до ffmpeg'а

Здесь тоже есть история с багом. Раньше для этого использовался обычный анонимный CreatePipe() — Windows‑примитив, который поддерживает только блокирующий синхронный ввод‑вывод: пишущий конец пайпа не умеет ни таймаутов, ни отмены. Под нагрузкой (когда ffmpeg на секунду тормозит с чтением) поток‑писатель мог зависнуть навсегда, ожидая WriteFile, что в итоге приводило к std::terminate(). Решение — именованный пайп с флагом FILE_FLAG_OVERLAPPED на своей стороне: ffmpeg по‑прежнему читает его как обычный синхронный пайп (он даже не знает что, собственно, поменялось), а вот пишущая сторона в HomRec теперь может ждать запись с таймаутом и корректно отменять операцию, если что‑то пошло не так, вместо того чтобы вешать весь поток захвата.

Instant Replay на той же трубе

Во время написания текста делается v2.1, и в ней «Save Replay» (F8): в фоне крутится буфер последних N секунд, и по хоткею последние секунды сохраняются в файл, без предварительного нажатия «начать запись».

Реализовано это не отдельным движком, а тем же самым hr_pl_set_recording() и той же именованной трубой из раздела выше — просто на другом конце трубы сидит ffmpeg, запущенный не с обычным -y output.mp4, а с мультиплексором сегментов: -f segment -segment_time 5 -segment_wrap N -reset_timestamps 1. Он режет входящий поток на файлы по 5 секунд (seg_000.mp4, seg_001.mp4,...) и переписывает их по кругу — значит, на диске всегда лежит примерно последние N × 5 секунд, а не бесконечно растущий файл.

По F8 происходит следующее: текущий сегментный процесс останавливается (чтобы последний, ещё не дописанный файл нормально финализировался), тут же запускается новый — в свежую, отдельно пронумерованную временную папку, чтобы случайно не подхватить старый сегмент от прошлого запуска. Пока новый процесс уже снова копит буфер, собранные файлы сортируются не по имени (имена переиспользуются при обороте по кругу) а по времени последней записи, обрезаются до нужного количества секунд и склеиваются в один ролик через concat‑демультиплексор ffmpeg — тот же инструмент, которым обычно склеивают части большого видео, тут просто на пять сегментов из буфера.

То есть по сути Instant Replay — это тот же самый механизм захвата и та же самая труба в ffmpeg, что и в обычной записи, только вывод устроен как кольцевой буфер файлов вместо одного растущего.

Оверлеи: вебкамера и GIF — не просто «картинка поверх превью»

До версии 2.0.3 (смешно, ведь на момент написания этой статьи последняя актуальная версия именно 2.0.3) оверлей с вебкамерой был красивой ложью: он позиционировался и рисовался в live‑превью, но в само записанное видео не попадал — просто предупреждение в лог и всё. Починили через Media Foundation (IMFSourceReader), а не через DirectShow — потому что ISampleGrabber/qedit.dll от DirectShow официально устарели и на современной 64-битной Windows не всегда регистрируются. Вебкамера читается в отдельном фоновом потоке, и композер оверлеев берёт «самый свежий из готовых» кадр каждый раз, когда собирает итоговое изображение — тот же принцип «всегда последнее, никогда не в очереди», что и у захвата экрана выше.

GIF‑оверлей устроен иначе, чем статичная картинка: все кадры декодируются заранее (через WIC), метаданные каждого кадра — время показа, способ очистки, позиция — читаются один раз при загрузке, а во время воспроизведения выбирается нужный кадр по реальному времени на часах. Так GIF проигрывается с той скоростью, с которой был анимирован, независимо от того, как часто пайплайн захвата успевает собрать кадр.

Плагины: почему Lua, а не Python

В первой статье я упоминал, что сперва думал сделать плагины на Python — и передумал. Причина прозаична: тащить интерпретатор Python со всеми его зависимостями в программу, которая обещает «минимум CPU и RAM на слабом железе» это полное противоречие самой идее софта. Lua 5.4 компилируется прямо в hr.exe, ничего лишнего не тянет и достаточно быстрый для скриптов, которые дёргают хуки вроде on_recording_start / on_recording_stop.

Плагин — это папка с plugin.json (простейший ручной JSON‑парсер под конкретно эту плоскую структуру, не универсальный) и main.lua, у которого есть доступ к файловой системе, сети (homrec.http_get/http_post) и API для тостов, цветов и событий между плагинами. Упакованный плагин — это .hrp‑файл, по сути переименованный zip (изначально планировалось сделать.jar, но всем же хочется «уникальности»:D): можно скачать и вручную бросить в plugins/, а можно через встроенный пакетный менеджер hom install <plugin> — оба пути кладут один и тот же файл на одно и то же место, просто hom избавляет от ручных шагов.

Что дальше?

Собственно, вот и весь фокус: нет никакой магии, просто на каждом шаге — от захвата до кодирования выбирался вариант с меньшей нагрузкой на слабое железо, даже если он требовал больше кода. Дальше буду разбирать что‑то конкретное по запросу — если есть тема, которая интересна отдельно: пишите в Issues на GitHub.

Исходники открыты