TL;DR
Я собрал плагин, в котором LLM-агент сам водит курсором по Windows-приложению и каждое своё действие пишет в журнал с точным временем. Из этого журнала — не из анализа видео — потом собирается демо-ролик: камера подъезжает к кнопке за 0,55 секунды до клика.
Рекордер, который висит на хуке мыши, узнаёт о клике в момент клика. Его камера либо догоняет, либо чинит это вторым проходом по уже снятому файлу. Здесь весь сценарий известен до первого кадра — поэтому ролик пересобирается с другими настройками, ни разу больше не запустив приложение.
Ниже — архитектура, три загубленных дубля с цифрами и два моих собственных вывода, которые развалились от замеров этой же работы.
Как это выглядело
Задача пришла не от меня. На потоке курса по вайб-кодингу ученица разбирала свою рабочую проблему: скринкасты продукта нужно снимать регулярно, и хочется, чтобы их снимала машина, а не человек. Мы пошли искать готовое — и не нашли (ниже раздел с двумя десятками проверенных репозиториев). Так что инструмент пришлось написать с нуля, прямо по ходу разбора.
Проверяли его на задаче, которая звучала буквально так: «открой калькулятор, посчитай 100 × 100, запиши демо». Дальше я не трогал мышь.
Агент нашёл окно, поставил его на нужный монитор, запустил запись, кликнул 1, 0, 0, ×, 1, 0, 0, =, остановился, отрендерил ролик и вернул строку вида «37.3s recorded → 27.7s output (17 segments)». В ролике 27,7 секунды: чёрные поля вокруг окна, мягкая тень, курсор с плавным следом. Камера наезжает на цифровую клавиатуру перед тем, как палец до неё дойдёт, а в финале на дисплее — 10 000.
Со второго дубля. Первый я расскажу отдельно, он поучительнее.
Весь код открыт: github.com/JHamidun/screencast-desktop, MIT. Ядро — 19 модулей на 5588 строк, тесты — 6 файлов на 1093 строки, документация — 5 файлов на 728 строк, звуковой банк — 26 wav. Цифры в статье привязаны к тегу v0.1.0: он зафиксирован специально, чтобы их можно было проверить в том же коде, который я тут описываю.
Кто узнаёт о клике первым
Разговор про «зум к клику» обычно упирается в спор, чей алгоритм плавнее. Спор пустой. Плавность тут вообще ни при чём — всё решает, кто и когда узнаёт о клике.
Возьмите любой рекордер, который сам находит места для приближения, — Screen Studio, его виндовые клоны, архивированный OpenScreen. Все они устроены одинаково: висит хук на мыши, приходит событие «нажали в точке (x, y)», и с этого момента камера начинает движение. Событие приходит в момент нажатия. Раньше оно прийти не может — до нажатия его не существует.

Слева зона, где данных ещё не существует. Справа — подъезд камеры, который начинается за 0,55 с до клика.
Что можно сделать с такими данными:
начать зум ровно на клике — камера едет уже по факту, зритель видит кнопку не в момент нажатия, а спустя полсекунды;
начать зум после клика, но замедлить кривую — то же самое, только мягче;
сделать второй проход по готовому файлу и смонтировать «задним числом» — это уже постобработка, и она требует, чтобы вся запись сначала легла на диск;
предсказать следующий клик по траектории курсора — работает, пока пользователь не передумал.
Третий пункт — единственный, который действительно даёт зум до клика в готовом файле. Ценой того, что дубль сначала должен целиком лечь на диск: пока идёт запись, камера живёт по первому или второму пункту. Остальные три либо опаздывают по построению, либо гадают.
Начать движение камеры заранее можно, только зная о событии заранее. Наверняка знает лишь тот, кто событие генерирует.
Когда сценарий пишет агент, координаты каждого клика, длительность каждого набора текста и порядок шагов известны до того, как записан первый кадр. Камера перестаёт быть реакцией и становится частью сценария. В server/camera.py это одна константа:
LEAD_IN = 0.55 # camera starts moving this long before the event
ZOOM_IN_DUR = 0.85
ZOOM_OUT_DUR = 0.7
HOLD_AFTER = 1.05 # a shot that carries information holds at least a secondи одна строка в сборке ключевых кадров:
start = max(0.05, g["start"] - LEAD_IN)g["start"] — момент первого клика в группе. Камера трогается на 0,55 секунды раньше и приезжает примерно тогда, когда курсор доходит до цели. Для рекордера живого человека это выражение невычислимо на лету: в момент g["start"] - 0.55 величины g["start"] ещё нет.
Отсюда же берётся вторая вещь. Имея весь список действий, можно сгруппировать соседние клики в один спокойный кадр вместо того, чтобы дёргать камеру на каждую кнопку:
MERGE_GAP = 3.6 # клики ближе этого по времени идут в один кадр
MIN_GROUP_ZOOM = 1.7 # но только если кадр остаётся достаточно тесным
close_in_time = p["t"] - g["end"] <= MERGE_GAP
stays_tight = _zoom_for(g["pts"] + [p], screen) >= MIN_GROUP_ZOOM
if close_in_time and stays_tight:
g["pts"].append(p)Решает тут крупность будущего кадра, а не близость точек. Если ради нового клика кадр придётся распустить ниже 1,7×, клик начинает свой кадр. Группировка только по расстоянию ломается в обе стороны: слишком мало — камера качает туда-сюда на каждом пункте списка, слишком много — весь ролик снят одним вялым общим планом. Онлайн-алгоритм такой критерий применить не может: он не знает, что будет через три секунды.
Архитектура: журнал как единственный источник правды
Правило в проекте одно, и я держал его жёстко: ни одна ступень конвейера не смотрит на видео, чтобы понять, что происходило. Всё берётся из журнала.

Янтарным — журнал и всё, что из него выводится. Серым — видео, которое попадает только в композитор.
сценарий агента ┌───────────────────────────────┐
(клик, ввод, ожидание) ─▶│ driver.Recorder │
│ move / click / type / press │───┐
└───────────────────────────────┘ │ каждое действие
│ со временем t
окно приложения ────────▶┌───────────────────────────────┐ │
│ recorder_proc → wgc │ │
│ Windows Graphics Capture │ │
└────────┬──────────────────────┘ │
│ ▼
raw.mp4 │ ┌────────────────┐
frame_times.json ├─────────────────▶│ events.json │
│ │ ЖУРНАЛ │
│ └───────┬────────┘
│ ┌───────────┴───────────┐
│ ▼ ▼
│ timeline.build camera.build
│ (сжать паузы) (ключи камеры,
│ │ LEAD_IN 0.55 с)
│ └───────────┬───────────┘
▼ ▼
┌────────────────────────────────────────────┐
│ composer.compose │
│ кадр по метке времени, зум, курсор, тень │
└───────────────────┬────────────────────────┘
▼ silent.mp4
┌────────────────────────────────────────────┐
│ sfx.sound_design + voice (озвучка) │
└───────────────────┬────────────────────────┘
▼ demo.mp4Формат журнала простой до скуки:
{
"screen": {"w": 2278, "h": 1426},
"origin": {"x": 1920, "y": 0},
"duration": 37.3,
"events": [
{"t": 0.0, "kind": "mark", "payload": "sync"},
{"t": 1.42, "kind": "move", "x": 640, "y": 880, "dur": 0.61},
{"t": 2.15, "kind": "click", "x": 640, "y": 880, "label": "digit-1"},
{"t": 3.04, "kind": "type", "payload": "100", "dur": 0.9},
{"t": 4.80, "kind": "key", "payload": "enter"}
],
"track": [[0.0, 1210, 700], [0.02, 1208, 698]]
}Четыре поля верхнего уровня и один список событий. screen — размер записанного кадра, origin — левый верхний угол того, что писали (журнал ведётся в координатах виртуального рабочего стола, который может уходить в минус на втором мониторе, и смещение вычитается ровно один раз, при выгрузке). track — плотно сэмплированный путь курсора: композитор рисует свой указатель, поэтому ему нужны все промежуточные позиции, а не только конечные точки перемещений.
Отдельным файлом рядом лежит frame_times.json — время захвата каждого кадра. Это не бухгалтерия ради бухгалтерии. Захват регулярно идёт чуть медленнее заказанных 30 fps, и кадр номер N лежит не в момент N/30. Если сопоставлять камеру с видео по индексу кадра, на подтормаживании машины камера уезжает с кликов. Поэтому сопоставление идёт по метке времени:
# advance through source frames until the next one would be in the future
while src_index + 1 < len(frame_times) and frame_times[src_index + 1] <= t:
if not cap.grab():
ended = True
break
src_index += 1
advanced = True
if advanced:
ok, f = cap.retrieve()grab() вместо read() тут не микрооптимизация. read() — это grab() плюс retrieve(), а retrieve() конвертирует и копирует картинку 2278×1426. Таймлайн схлопывает паузы между действиями, поэтому большинство исходных кадров пролистываются и никогда не рисуются. Платить за их распаковку — это 26 секунд из 66 на рендер.
Побочный эффект такого учёта — бесплатный инвариант. Число кадров в raw.mp4 обязано совпасть с длиной списка frame_times. На дубле в 92,9 секунды — 2786 против 2786. Не сошлось — значит, писатель кадров и энкодер разъехались, и это видно ещё до того, как ролик кто-то откроет.
Сжатие пауз
Пока агент думает, экран пишется. В докстринге модуля остался замер с ранних прогонов: семь кликов подряд заняли 96 секунд, из которых что-то происходило секунд двадцать. Смотреть такое невозможно, а рендерить — вчетверо дороже, чем нужно.
timeline.py строит отображение записанного времени в выходное:
KEEP_BEFORE = 1.1 # сколько держим до действия
KEEP_AFTER = 1.5 # и после, чтобы увидеть результат
IDLE_KEEP = 0.55 # схлопнутая пауза всё равно получает столько
MIN_GAP = 1.4 # паузы короче этой не трогаем
for m in marks:
a, b = max(0.0, m - keep_before), min(duration, m + keep_after)
if windows and a <= windows[-1][1] + 0.25:
windows[-1][1] = max(windows[-1][1], b) # окна интереса сливаются
else:
windows.append([a, b])Через это же отображение проходят и ключевые кадры камеры, и выбор исходного кадра, и момент, когда срабатывает круг-«пульс» на клике. Одна функция to_src() — и всё остаётся в фазе. На калькуляторе получилось 37,3 секунды записи → 27,7 секунды ролика, 17 сегментов, 21 ключевой кадр камеры, зум в диапазоне 1,0–2,0.
Запись — отдельным процессом
Windows Graphics Capture хочет свой поток и свой COM-апартамент, а цикл кодирования живёт столько же, сколько дубль. Первая попытка запустить это внутри вызова MCP-инструмента заклинила сервер: вызов блокировался минутами, и файла не появлялось. Захват переехал в отдельный процесс. Сервер только стартует его, а останавливает тем, что кладёт в рабочую папку файл-флаг stop.
Сам WGC событийный: кадр приходит, когда что-то изменилось. Видео нужен ровный поток, поэтому писательский поток по фиксированным часам пересылает в ffmpeg последний полученный кадр:
period = 1.0 / self.fps
next_t = time.perf_counter()
while self._run:
with self._lock:
buf = self._latest
if buf is not None and self._ff and self._ff.stdin:
self._ff.stdin.write(buf) # numpy отдаёт буфер напрямую,
self.frame_times.append(time.perf_counter()) # без копии в 26 МБ
self.frames_written += 1
next_t += period
delay = next_t - time.perf_counter()
time.sleep(delay) if delay > 0 else NoneНоль времени берётся не из системных часов. Перед первым действием на записываемом мониторе вспыхивает белое полноэкранное окно на 0,18 секунды — задержку старта захвата снаружи ffmpeg узнать нельзя, поэтому в само видео кладётся яркий однозначный кадр, к которому потом привязывается таймлайн. Вспышка обязана попасть на тот монитор, который пишется: полыхнёт на основном, пока идёт запись соседнего, — и таймлайн остался без якоря.
Провалы, с цифрами
Теперь про то, чего в README не пишут.
Дубль 18,1 секунды и ноль кликов
Первая попытка с калькулятором. Запись стартовала, пока агент ещё разбирался, где окно и что нажимать. Восемнадцать секунд честного видео неподвижного калькулятора, clicks logged: 0. Выброшен целиком.
Отсюда простое правило, которое стоило дубля: запись включается последним шагом подготовки, после того как окно найдено, поставлено и активировано. Не «начнём писать, потом разберёмся».
Дубль на 93 секунды, в журнале 7 событий и все — click
Вторая задача была сложнее: Paint, три фигуры разных цветов. Провалена дважды подряд.
Строка отчёта после остановки выглядела так: events: 7, kinds: {click: 7}. Дубль длиной 92,9 секунды, в котором агент нажимал Enter три раза, — и ни одного key в журнале.

Три Enter, не дошедшие до журнала, и приговор в отчёте: events: 7, kinds: {click: 7}.
Причина в интерфейсе плагина. Клики шли через инструмент, который пишет событие в журнал. Нажатия Enter — через другой путь, который до журнала не доходил. Метод press() в драйвере существует и событие key создаёт:
def press(self, *keys: str, label: str = "") -> None:
t = self.now()
codes = [VK[k.lower()] for k in keys]
for c in codes:
_key_vk(c)
time.sleep(0.05)
for c in reversed(codes):
_key_vk(c, up=True)
self.events.append(Event(t=t, kind="key", payload="+".join(keys), label=label))Но у агента был способ нажать клавишу мимо этого метода. Журнал остался без трёх событий, а вместе с ними без трёх точек, на которые должна была смотреть камера.
Клавиши тут ни при чём. Дефект в архитектуре доступа: если журнал объявлен единственным источником правды, ни одного пути к приложению мимо журнала быть не должно. Иначе получается тихая частичная слепота — конвейер отработает без единой ошибки и соберёт ролик, в котором просто нет половины сюжета.
Схлопнувшаяся панель и два лишних прямоугольника
Вторая часть того же провала. В ролике вместо круга и треугольника появились два лишних красных прямоугольника.
Панель инструментов Paint схлопывается от клика по холсту. Агент кликнул по холсту, панель сложилась, и координаты кнопок «круг» и «треугольник», снятые раньше, стали координатами холста. Дальнейшие клики честно ушли туда, куда им сказали, — в холст, где активным оставался прямоугольник.
Обнаружилось это на 93-й секунде, по случайному снимку экрана. Полторы минуты агент уверенно работал по устаревшей карте координат и ни разу об этом не узнал.
Правильный ответ — не «снимать координаты чаще», а проверять попадание перед действием. В UI Automation есть ControlFromPoint: даёшь точку — получаешь контрол, который в ней лежит. Я замерил стоимость: медиана 9,2 мс, минимум 6,1, максимум 44,3 (20 замеров по разным точкам экрана). Первый вызов — 254 мс, это инициализация COM, дальше кэш.
Девять миллисекунд. При кликах, между которыми паузы в сотни миллисекунд, проверка «а под курсором действительно кнопка „круг“?» бесплатна. Стой она 200 мс — я бы от неё отказался. На девяти она обязательна перед каждым кликом.
900,07 секунды в пустоту
Ещё один дубль никто не остановил как надо. На диске остался raw.mp4 длиной 900,07 секунды — пятнадцать минут записи неизвестно чего, — и никакого events.json рядом.
Пятнадцать минут — не совпадение. Это сторож в процессе-рекордере:
# A take that is forgotten should not run forever.
deadline = time.perf_counter() + 15 * 60
while not stop_flag.exists() and time.perf_counter() < deadline:
time.sleep(0.2)Сторож сработал ровно так, как задуман, и спас диск. Но файл всё равно оказался бесполезен, потому что журнал пишется только в record_stop:
log = drv.dump(str(out / "events.json"), stage={"w": size[0], "h": size[1]})Не дошли до record_stop — журнала нет. Сырое видео без журнала в этой архитектуре — мусор: собирать по нему нечего, а анализировать видео плагин принципиально не умеет.
Если бы я делал это заново, журнал писался бы инкрементально — по событию, а не одним дампом в конце. Дублю в 900 секунд это не помогло бы (там и событий не было), но любой обрыв на середине оставлял бы монтируемый материал.
Ложный диагноз, из-за которого остановили исправную запись
Самый обидный. В какой-то момент снимок экрана «окна калькулятора» вернул чужое окно — редактор, который лежал сверху. Вывод напрашивался: запись пишет не то, надо всё останавливать.
Вывод был неверный. Инструмент снимка окна кропает экран по прямоугольнику окна, поэтому всё, что лежит поверх, попадает в кадр. А запись в это время шла через WGC, которая пишет окно, а не область экрана, и содержимое перекрывающих окон в неё не попадает. Запись была честной. Остановили исправное.
Запомнил на будущее: «инструмент наблюдения» и «инструмент записи» могут по-разному понимать один и тот же аргумент, а расхождение между ними читается как поломка записи.
Мониторы, которые не мониторы
Мелочь того же класса: place_window(monitor=2) в один момент поставил окно на основной экран, а через минуту с тем же аргументом — на соседний. Нумерация мониторов у разных подсистем Windows своя, и совпадать она не обязана. Полагаться на номер нельзя, надо проверять получившиеся координаты.
Два моих вывода, которые оказались неверными
Разбор провалов делали четыре субагента, а поверх прошли ещё три независимые проверки. Часть моих собственных выводов эти замеры и развалили. Это самая полезная часть истории, поэтому привожу её целиком.
Вывод №1: «перетаскивание не переживает разнос по процессам»
Я был уверен, что drag-and-drop ломается, когда генерация ввода и целевое приложение живут в разных процессах: мол, Windows видит разрыв в потоке ввода.
Критерий взял простой и проверяемый: сделать в Notepad выделение перетаскиванием, нажать Ctrl+C и посмотреть в буфер обмена. Пусто — не сработало.
Вариант | Результат | |
|---|---|---|
| Paint рисует, Notepad не выделяет | |
| ABSOLUTE), один процесс | выделяет |
| выделяет |
Разнос по процессам не ломает ничего. Настоящих причин у симптома оказалось две, и обе не про процессы:
WinUI следит за потоком ввода, а не за позицией курсора. SetCursorPos двигает указатель, но не порождает событий ввода. Для Paint на GDI этого хватает, а WinUI-приложение видит курсор в новом месте и ни одного сообщения о движении — и молча ничего не делает. Не ошибка, не исключение, просто тишина.
В Paint в тот момент не был выбран инструмент. Мышь ездила, рисовать было нечем.
Две несвязанные причины сложились в один симптом, и симптом уверенно указал на третью, несуществующую.
Для драйвера отсюда следует простая вещь: движение курсора и события ввода — не одно и то же. В driver.py перемещение делается через SetCursorPos (плавная дуга с переменной скоростью и лёгким перелётом в конце), а нажатия — строго через SendInput. Для клика этого достаточно. Для честного drag траекторию тоже нужно гнать через SendInput с флагами MOVE|ABSOLUTE.
Вывод №2: «UWP не отдаёт дерево контролов»
Обходчик плагина возвращал по калькулятору пустоту. Вывод напрашивался сам: UWP-приложения закрыты, дерево через UI Automation не достать, надо распознавать элементы по картинке.
Прямой замер:
через ApplicationFrameWindow — 88 элементов, из них 83 именованных;
через CoreWindow — 80 элементов.
Дерево отдаётся прекрасно. Сломан был обходчик, а не платформа.
Зато на этом же замере нашлась настоящая ловушка UWP, и она куда неприятнее: Windows Graphics Capture не умеет захватывать CoreWindow. Падает с «Неверный дескриптор (0x80070006)». Писать нужно с ApplicationFrameWindow — того самого внешнего окна-обёртки, через которое, кстати, и дерево полнее.
Порядок рассуждения тут показательный: сначала не сработал мой код, потом я сделал из этого вывод о свойствах чужой платформы, а настоящее ограничение платформы — в соседнем API — так и осталось незамеченным.
Третья находка того же класса: 45 пикселей
Всю запись камера и нарисованный курсор стояли чуть выше цели. По картинке это читается как «камера немного промахивается» — то есть как вкусовой недостаток алгоритма, а не как дефект.
Дефект. Кадр WGC начинается не с клиентской области окна, как записано в комментарии моего же модуля захвата:
def client_origin(hwnd: int) -> tuple[int, int]:
"""Screen position of the window's client area top-left.
WGC frames start at the client area, not the window frame, so this is the
offset to subtract from screen coordinates to get frame coordinates.
"""Кадр начинается с DWM extended frame bounds — того, что отдаёт DwmGetWindowAttribute с атрибутом 9. У окна с системным заголовком расхождение между этими двумя прямоугольниками равно высоте заголовка: при масштабе 150 % это 45 физических пикселей.
Сорок пять пикселей систематического сдвига по вертикали во всём, что композитор рисует поверх видео. Заметить их глазом можно, а вот отличить «сдвиг на постоянную величину» от «камера чуть неточно наводится» — нельзя, если не знать, что искать. Ошибка на постоянную величину — худший вид ошибки: она не выглядит как ошибка.
На момент публикации это не починено, комментарий в коде выше — тот самый неверный.
Замеры, которые изменили решения
Три числа, каждое из которых поменяло что-то в коде или в планах.
Hit-test — 9,2 мс. Разобран выше. Дёшево настолько, что проверка «под курсором то, что я думаю?» перестаёт быть роскошью и становится нормой перед каждым кликом.
Зерно плёнки — 95 % веса файла. В композиторе есть косметическое зерно, grain_amount = 0.045. A/B на одном и том же дубле, одним и тем же энкодером:

Косметическое зерно: 9,54 МБ против 0,49 МБ при разнице в 1,2 уровня яркости из 255.
grain | 6 секунд ролика | битрейт |
|---|---|---|
0.045 | 9,54 МБ | 12 727 кбит/с |
0.0 | 0,49 МБ | 662 кбит/с |
Разница — минус 95 % веса. За что платим: измерил межкадровую разницу через cv2 на 8 кадрах в двух областях — зерно даёт колебание яркости ±1,2 уровня из 255, а пространственный разброс не меняет вообще (94,51 против 94,51).
Плёночное зерно случайно от кадра к кадру, поэтому межкадровое кодирование на нём разваливается: энкодеру нечего предсказывать. Половина процента яркости, которой почти не видно, стоит двадцатикратного файла.
Заодно измерил, во что вообще упирается вес. Готовый ролик — 43,6 МБ на 28,7 секунды, около 12 Мбит/с (NVENC, настроенный на скорость). Перекодировка в том же разрешении x264 slow crf 23 — 5,61 МБ, в 7,5 раза меньше. В 720p — 2,46 МБ. Для сравнения, исходная запись калькулятора весит 199 КБ на 37,3 секунды. Статичный светлый интерфейс жмётся почти в ничто, а вес создаёт всё, что мы навешиваем сверху: градиентный фон, тень, курсор, зерно.
YAVG бесполезен как критерий «кадры разные». Я хотел дешёвую автоматическую проверку, что в ролике что-то происходит: прогнать signalstats и посмотреть на среднюю яркость. На светлом интерфейсе средняя яркость всего дубля — 178,7, и она 178,7 на всём протяжении. На 93-секундном дубле разброс между кадрами — 0,705 уровня из 255.
Ролик, в котором агент кликал не туда и рисовал не то, по этой метрике неотличим от идеального. Проверять «что-то происходит» надо по журналу — сколько событий каких типов, — а не по видеосигналу. Тот дубль с семью click и нулём key такая проверка поймала бы за секунду до рендера.
Ещё пара чисел из эксплуатации, чтобы не создавать впечатление гладкости: холодный импорт MCP-сервера — 2,3 секунды на тёплом кэше, и один раз за сессию сервер отвалился по CONNECT_TIMEOUT, показав 33 785 мс при лимите 30 000. Двадцать инструментов, тяжёлые зависимости и время старта — реальная цена, которую платит каждая сессия.
Что есть в мире и почему полного аналога нет
Перед публикацией я прошёл около двадцати репозиториев — искал, не изобретаю ли велосипед. Полного аналога (Windows + агент кликает сам + журнал с таймингами до съёмки + монтаж из журнала + озвучка) не нашлось. Нашлись три половины и много соседей.
Проект | Звёзды | Что делает | Лицензия |
|---|---|---|---|
56 | ровно идея «монтаж из журнала», но по браузерным трейсам Playwright: авто-зум по координатам действий, TTS, субтитры | MIT | |
11 | Windows-рекордер: WGC, QPC-таймстампы ввода, пружинная камера. Без агента, создан в июне 2026 | Apache-2.0 | |
16 | агент-скилл с полным конвейером и dry-run — но только macOS | MIT | |
39,9k | «анти-Screen Studio», авто-зум за курсором; архивирован, последняя активность 17.06.2026 | MIT | |
21,2k | open-source Loom, Rust/Tauri | AGPLv3, часть крейтов под MIT | |
9,6k | агент управляет Windows через UIA + Win32 + COM — видеовыхода нет | MIT | |
6,8k | MCP-сервер управления Windows, 20 инструментов — записи видео нет вообще | MIT | |
25,3k | скриншот → структурные элементы, запасной путь там, где UIA пусто | CC-BY-4.0 | |
1,6k | кривые Безье и закон Фиттса для человекоподобного курсора | MIT | |
4,0k | UI-автоматизация Windows от Microsoft; последний стабильный релиз — v1.2.1, ноябрь 2020 | MIT |

Четыре браузерных проекта появились за две недели марта 2026 — и пустой квадрант там, где Windows.
Четыре причины, почему в середине таблицы дырка.
Две общины не пересекаются. Рекордеры пишут видео-люди, которым агент не нужен и даже мешает. Агентные проекты пишут автоматизаторы, которым видео не нужно: UFO и Windows-MCP управляют Windows отлично и не производят ни одного кадра.
Где журнал даётся даром, аналог уже есть. В браузере трейс Playwright содержит все действия с таймингами. Как только агенты научились надёжно водить браузер, идея «монтировать из журнала» пришла в голову сразу нескольким людям: четыре независимых проекта появились в течение двух недель марта 2026 года, playwright-recast из таблицы — один из них. На Windows журнала никто не выдаёт: его надо строить самому, вместе с драйвером ввода, и это работа, которую никто не делает ради видео. За те же месяцы на Windows не появилось ни одного такого проекта.
Половина функции без агента бессмысленна. Рекордеру, за которым сидит человек, журнал действий даёт разве что зум по кликам — это и через хук получается, OpenScreen тому пример. Ценность расписания появляется, когда сценарий кто-то пишет заранее: тогда съёмку можно спланировать под будущий монтаж, а ролик пересобрать, не запуская приложение снова. Писать под это отдельный слой, если за мышью всё равно сидит человек, незачем.
Windows-специфика дорогая. WGC по окну вместо Desktop Duplication — на гибридном ноутбуке ddagrab отдал ровно один кадр со второго адаптера и замолчал. DPI-осведомлённость до чтения первой координаты, иначе Windows рапортует процессу размеры, поделённые на масштаб, и все координаты уезжают. Плюс монотонные часы, чётные размеры кадра под yuv420p и грабли UI Automation. Ничего из этого не нужно на macOS и ничего не переиспользуется из браузерных проектов.
Отдельно про WinAppDriver. Официальный инструмент Microsoft для UI-автоматизации Windows выпустил последний стабильный релиз 5 ноября 2020 года — v1.2.1. После него вышел только кандидат v1.2.99 летом 2021, и релизов с тех пор нет, хотя отдельные коммиты в репозитории появляются вплоть до апреля 2025. Нишу бросили до того, как появились агенты, которым она понадобилась.
Что не работает и что бы я сделал иначе
Честный список. Ничего из перечисленного не «в планах» — это то, что сегодня либо сломано, либо работает не так, как должно.
Сломано или недоделано
Сдвиг на высоту заголовка не исправлен. Те самые 45 пикселей. Знаю причину, знаю API, не починил на момент публикации.
Клавиши журналируются не всегда. Метод press() пишет событие key, но у агента остаётся путь нажать клавишу мимо него. Пока это так, «журнал — единственный источник правды» — намерение, а не гарантия. Документацией такое не закрывается. Закрывается тем, что мимо журнала физически нет способа тронуть приложение.
Журнал пишется одним куском в конце. Оборвался дубль — материала нет вообще, хотя видео на диске. Инкрементальная запись по событию решала бы это целиком.
Нет проверки попадания перед кликом. При стоимости 9 мс её отсутствие нечем оправдать. Провал с панелью Paint — прямое следствие.
Нет автоматической проверки, что дубль вообще годен. Простейшая: если кликов ноль, или все события одного типа, или число key не совпадает с числом нажатий в сценарии — не рендерить, а вернуть ошибку. Дубль 18,1 с и дубль на 93 секунды оба отсеялись бы до рендера. Через YAVG такое не ловится (0,705 из 255 разброса), через журнал — тривиально.
Номер монитора ненадёжен. Один и тот же аргумент дважды дал разный результат. Нужно проверять фактические координаты окна после расстановки, а не доверять номеру.
Неверные умолчания
Зерно включено по умолчанию. 0,045 стоит 95 % веса файла при ±1,2 уровня яркости. Дефолт должен быть 0,0, а зерно — осознанным выбором для конкретного ролика.
Профиль вывода настроен на скорость, не на вес. 12 Мбит/с NVENC против 5,61 МБ у x264 slow crf23 на том же материале. Для ролика, который смотрят в браузере, это неверный размен.
Ограничения, которые никуда не денутся
Записывать умеет одно окно за раз. Демо, где приложение обменивается данными со вторым окном, снять нельзя. Ограничение архитектурное: WGC привязан к дескриптору окна, и это же ограничение — причина, по которой в кадр не лезет чужое содержимое.
Сервер не бесплатен. Двадцать инструментов, 2,3 секунды импорта, один отвал по таймауту за сессию. В сессии, где скринкаст нужен один раз из ста запросов, это постоянный налог.
Что бы я сделал иначе, если бы начинал сегодня: сначала журнал и его валидация, потом драйвер, и только потом камера и красота. Я шёл в обратном порядке — сперва добился, чтобы ролик выглядел прилично, — и оба провальных дубля были провалами журнала, а не картинки. Красивый ролик про то, как агент рисует не те фигуры, всё равно идёт в мусор.
Чек-лист, если будете делать похожее
Не про мой код — про класс задач «агент делает и одновременно снимает».
Журнал — единственный вход. Ни одного пути воздействия на приложение мимо него. Намерение тут не считается — считается отсутствие второго пути в API.
Валидируйте дубль по журналу до рендера. Ноль кликов, все события одного типа, расхождение с ожидаемым сценарием — стоп. Рендер дорогой, проверка бесплатная.
Ноль времени — внутри видео. Синхронизирующая вспышка на записываемом мониторе. Системные часы и время старта процесса врут на неизвестную величину.
Пишите время каждого кадра. Сопоставление камеры с видео — по метке времени, не по индексу. Заодно получите инвариант «кадров в файле = длина списка».
Захват — отдельным процессом. Иначе он заклинит того, кто его запустил.
DPI-осведомлённость — до первой прочитанной координаты. Не после.
Проверяйте попадание перед действием, если это дёшево. 9 мс — дёшево. Интерфейс приложения меняется от ваших же кликов.
Метрики видеосигнала не годятся для «что-то происходит». Средняя яркость на светлом UI постоянна с точностью до одного уровня из 255.
Косметику меряйте в мегабайтах. Зерно, свечение, шум — всё, что случайно от кадра к кадру, убивает межкадровое сжатие.
Разделяйте «свой код не работает» и «платформа не умеет». Оба моих неверных вывода — ровно эта путаница, и оба раза настоящее ограничение платформы лежало рядом и осталось незамеченным.
И одиннадцатое, из другой области. Перед выкладкой репозитория в открытый доступ я прогнал его сканерами утечек — чисто. Затем открыл глазами тестовый PNG, лежавший в артефактах: снимок почтового ящика с адресом, темами писем и именами приватных репозиториев. Текстовые сканеры такое не ловят в принципе — они читают текст, а утекло изображение. Всё, что публикуете, смотрите глазами, а не только грепом.
Забрать каркас
Если из всего этого нужна одна идея, то вот она: когда действия генерирует агент, монтаж перестаёт быть анализом видео и становится чтением расписания. Камера, сжатие пауз, момент срабатывания эффекта на клике, выравнивание озвучки — всё это функции от журнала, и ни одна ступень не смотрит в картинку. Отсюда и зум за полсекунды до клика, и возможность пересобрать ролик с другими настройками, ни разу больше не запустив приложение.
Код лежит целиком в github.com/JHamidun/screencast-desktop под MIT. Ставится в Claude Code как плагин, дальше /screencast-desktop:setup проверит машину и скажет, чего не хватает.
Три отдельно переиспользуемых куска, если целиком не нужно: timeline.py (121 строка — сжатие пауз по списку событий), camera.py (213 строк — журнал в ключевые кадры) и запись окна через WGC с отдельным процессом-рекордером.
Если хотите так же
Затык у большинства случается не на идее, а на установке: поставить Claude Code или Codex, разобраться с регистрацией, настроить конфигурацию так, чтобы агент делал что-то полезное, а не отвечал общими словами. У меня на это ушло больше года, и результат я собрал в одном месте — это Ника, мой агент-помощник: t.me/vibecodeguidebot.
агент, обученный на документации Anthropic и моих рабочих наработках, — отвечает по ходу, от регистрации до первого рабочего проекта;
моя конфигурация Claude Code целиком: правила, скиллы, агенты, команды, плагины — та самая, которой написан плагин из этой статьи;
установщик и семиминутный видео-гайд, чтобы не собирать это по кускам.
Бесплатно, регистрация не нужна.
Про инженерные эксперименты с агентами и автоматизацией пишу в канале «Готовим ИИшницу». Разборы проектов и контакты — мой сайт.
Разборы проектов и контакты — мой сайт.