58 рекламных роликов, которые я не снимал, а закоммитил


Мой главный актёр не умеет повторять дубль. Попросил его сказать фразу - он сначала зевнул, потом ушёл к краю экрана и только потом сказал. Поправил текст реплики - «тот же самый» дубль уже не снять, будет другой путь и другая поза.
Актёр - это Mewmori, пиксельный кот, который живёт на рабочем столе Mac. Ходит по окнам, спит на доке, болтает через локальную нейросеть. Мой пет-проект, который как-то незаметно оброс пользователями.
Продукт без роликов никто не видит, так что контент-план я себе написал бодрый: три вертикальных видео в неделю, снятые пачками. Руками через дебаг-панель три ролика в неделю не наснимаешь. Долго - это полбеды. Хуже, что дубль не повторяется: у кота поведенческий цикл, настроение, своя голова.
Я разработчик, поэтому решил сделать то, что умею: превратить съёмку в код. Спойлер: получилось, но не с первого раза и даже не с десятого 🤡
Как это выглядит сейчас
Ролик - это JSON-раскадровка в репозитории. Вот почти целиком одна из них, кот радуется зелёному билду в Xcode:
{
"name": "build-ok-en",
"scene": "xcode-ok-en",
"settle": 0.6,
"tail": 1.2,
"catHeight": 280,
"steps": [
{ "action": "move", "x": 0.5, "y": 0.39, "style": "teleport" },
{ "action": "look", "skin": "classic_cat" },
{ "action": "mood", "value": "hiding" },
{ "action": "wait", "seconds": 1.5 },
{ "action": "mood", "value": "idle" },
{ "action": "wait", "seconds": 0.8 },
{ "action": "effect", "value": "sparkle_pop" },
{ "action": "clip", "value": "react_cheer" },
{ "action": "say", "text": "Green. I never doubted you.", "hold": 2.2 },
{ "action": "effect", "value": "confetti" }
]
}
(Это файл из репозитория как есть, я только убрал поле note с комментарием для себя.)
Дальше одна команда:
python3 scripts/record-promo.py promo/storyboards/build-ok-en.json
Скрипт поднимает виртуальный экран, запускает Debug-сборку приложения, кот играет раскадровку, экран пишется, звук пишется, на выходе mp4. Правка реплики - это правка одной строки и повторный запуск. И кот тот же, и сцена.
А так выглядит уже готовый ролик после монтажа:
Дубль away-en снят тем же скриптом во весь экран (кот там ходит по столу), а камеру поверх собрала HTML-композиция. Это тот самый ролик, где расчётная реплика разошлась с настоящей почти на пять секунд, но об этом ниже.
Сейчас в promo/storyboards/ 58 раскадровок, в promo/out/ 67 готовых роликов. Первый коммит с этим режимом - 4 августа.

Как устроено внутри

Кот играет сценарий, но только в Debug
Весь съёмочный режим лежит в Mewmori/Sources/Common/Promo/: 5 файлов, около 1800 строк, и каждый начинается с #if DEBUG. В релиз это не попадает вообще.
Дубль - это обычный сценарий кота, только его нельзя перебить. У меня поведение кота крутится в акторе ScenarioEngine, и любой сценарий можно перебить более важным (кот проголодался, например). Съёмочный перебить нельзя, иначе посреди дубля кот уходит есть.
Шагов в формате немного: say, mood, clip, effect, reaction, look (скин и одежда), move (walk, jump, teleport), wait, cursor. Координаты в долях экрана от нижнего левого угла, а не в пикселях. Так одна раскадровка работает на любом разрешении.
Синхронизация через файлы
Скрипт и приложение общаются максимально тупо, через файлы в promo/.work/:
Кот запускается и ждёт файл-отмашку
cue.Скрипт стартует
screencaptureи кладётcue.Кот отвечает файлом
startedи играет дубль.В конце пишет
report.jsonс таймингами и своей рамкой.Скрипт останавливает запись, обрезает, кадрирует.

Папка .work специально не чистится после сорванного дубля. Сорванный дубль - это улика, его надо разобрать.
Приложение запускается не через open, а напрямую бинарником с аргументами -promo-storyboard, -promo-cue и так далее. Через open -n кот поднимался с теми же аргументами (это видно в ps), отмашку получал, но раскадровку не начинал, и скрипт ждал отчёт до таймаута. Прямой запуск играет тот же дубль, а бонусом stdout кота виден в терминале. Плюс -AppleLocale рядом с языком, потому что на моей русской машине английский кот говорил «1,4 GB» с запятой.
Кот сам говорит, где он
Моя любимая часть (и та, что потом не дожила, но об этом ниже). Кот состоит из нескольких окон: само тело, отдельное окно-цель для кликов, облачко с репликой. Чтобы обрезать видео, надо знать, где всё это было за дубль. Угадывать по картинке не хотелось, поэтому кот сам записывает свою рамку каждые 60 мс:
private func sample() {
for window in NSApp.windows {
guard window.isVisible,
window.alphaValue > 0.01,
let identifier = window.identifier?.rawValue,
identifier.hasPrefix("Mewmori.") else { continue }
bounds = bounds.map { $0.union(window.frame) } ?? window.frame
seen[identifier] = window.frame
}
}
Окна с alpha около нуля не считаем: спрятанное облачко иначе раздувало рамку. Объединение рамок за весь дубль уходит в отчёт, и скрипт режет по нему вертикаль.

Почему 540 точек
Вертикаль 1080×1920. На Retina 2x 540 точек ширины дают ровно 1080 пикселей, то есть кадр без растяжки. Поэтому минимальная ширина кропа 540, а кот на съёмке 200 точек вместо обычных 100, в крупных планах до 280 (как в примере выше). Спрайты, к счастью, изначально рисовались с запасом, текстуры там по 475-600 px, и в крупном плане не мылятся (редкий случай, когда прошлый я подумал о будущем мне).
Время режем с конца
screencapture разгоняется 1-2 секунды, и каждый раз по-разному. Отсчитывать начало дубля от старта записи бесполезно. Поэтому скрипт считает от конца файла: длительность дубля кот знает точно, она в отчёте.
Звук через BlackHole
Кот у меня мяукает и мурчит (звуки, кстати, синтезируются кодом, аудиофайлов в проекте нет ни одного). Чтобы их записать, скрипт перед дублем переключает системный вывод на BlackHole 2ch маленьким Swift-хелпером на CoreAudio (его исходник лежит строкой прямо в Python-скрипте и запускается через swift -e), а в finally возвращает обратно.
Грабли: если дубль упал раньше finally, вывод так и остаётся на BlackHole, и следующий запуск честно «восстанавливает» его на тот же BlackHole. Ещё кот пишется тихо, примерно -31 LUFS, и в ленте его не слышно. Скрипт дотягивает до -16 с потолком -1 dBTP.
Тест на опечатки
Самая подлая ошибка формата: опечатка в имени клипа не роняет дубль. Кот просто молча ничего не делает. Узнаёшь об этом, когда смотришь готовое видео.
Поэтому есть обычный XCTest, который проходит по всем раскадровкам и сверяет имена с каталогом спрайтов и одежды:
switch step {
case let .clip(clip):
XCTAssertTrue(catalog.clipNames.contains(clip), "\(file): нет клипа '\(clip)'")
case let .effect(effect):
XCTAssertTrue(catalog.effectNames.contains(effect), "\(file): нет эффекта '\(effect)'")
Там же проверяется, что английская раскадровка идёт на английском столе. Раскадровки теперь падают в тестах как любой другой код 💪
Чем оплачены правила
Каждое правило выше стоит минимум одного испорченного дубля. Самые запомнившиеся:

Кот прошёл 6% ширины экрана. Рамка по его маршруту вышла 704×1250 точек, кот занял в ней седьмую часть, получился ролик про обои. На 2% кадр 564×1004 и нормальный. Правило: в вертикали кот ходит в пределах пары процентов.
Кот начинал там, где закончил вчера. Рамка - объединение всех позиций. Если дубль начинается с прошлого места кота, а потом он телепортируется в центр, рамка раздувается вдвое. Лечится порядком: сначала телепорт, потом отмашка, и только после неё замер.
Кот пришёл одетым. В день выхода осеннего набора дубль вышел с котом в шапке, шарфе и дождевике, хотя раскадровка просила голого classic_cat. Аксессуары надевались примеркой поверх образа, сохранённого на моей машине. С тех пор look собирает образ с нуля, ещё до отмашки.
Голова отвалилась. Клип wake_sleepy поверх сидящей позы опускал голову на 27 точек вниз от тела. Выглядело как хоррор. Теперь клипы, которые принадлежат другому состоянию, отклоняются с понятным событием в логе.
Сняли релиз вместо Debug. В релизе съёмочного режима нет, кот просто жил своей жизнью, а скрипт ждал до таймаута. Полчаса искал, в чём дело. Так что скрипт проверяет сборку до запуска:
info = arguments.app / "Contents" / "Info.plist"
bundle = plistlib.loads(info.read_bytes()).get("CFBundleIdentifier", "")
if not bundle.endswith(".debug"):
fail(f"{arguments.app} собран не в Debug (bundle id {bundle}): съёмочного "
"режима в нём нет. Собери схему Mewmori в Debug в свою -derivedDataPath")
Не тот экран и чужие окна. Однажды пачку дублей записал на основном мониторе вместо виртуального, а при закрытой крышке ноутбука нумерация экранов вообще съезжала. Был ещё режим «без нарисованного стола», на живом рабочем столе: ролик «упал билд» трижды вышел браком, а однажды в кадр попало окно соседнего терминала. Оба выхода я закрыл. Ключ выбора экрана из скрипта убран, снимается только виртуальный 1728×1117@2x через приватный CGVirtualDisplay. А за котом всегда нарисованный стол из scene.json: обои, меню, док, окна. Хочешь Xcode с красным билдом - это отдельная сцена-картинка.
Русская раскладка. В том же ролике «упал билд» сборку в Xcode запускал AppleScript: keystroke с ⌘B. При русской раскладке он молча ничего не нажимает. Никакой ошибки. Помогли позиционные key code.
Имя пользователя в кадре. Дубль про установку: после копирования Finder сам открыл «Программы» поверх того же места, и в кадр попал список моего софта вместе с именем пользователя. Кроп по окну защищает, только пока окно на месте. Хвост каждого дубля с тех пор смотрю контактным листом: contact-sheet.py собирает из видео одну картинку с сеткой кадров и подписанными секундами.
Что не получилось
Красивая идея «кот сам сообщает рамку, скрипт режет вертикаль» до конца не дожила.
Для роликов, где кот стоит или сидит, автокроп работает отлично. А вот когда кот ходит, рамка либо огромная, либо дёргается при склейке. 13 октября я забраковал очередной ролик именно из-за прыгающей рамки. Сейчас ходячие ролики снимаются полноэкранным дублем, а камеру поверх ставлю отдельно, в HTML-композиции (HyperFrames). Раскадровка и сам дубль остались кодом, а «оператор» переехал в другой слой.
И детерминизм не абсолютный. Расчётные тайминги однажды разошлись с реальностью почти на пять секунд: по плану реплика на 6-й секунде, кот заговорил на 10,8. Смена настроения, ходьба и печать реплики занимают время, которого в раскадровке не написано. Теперь моменты реплик замеряет сам кот и пишет в .beats.json, а не я калькулятором. А в роликах с голосом за кадром ещё и локальный синтез диктора (Kokoro) каждый раз выходит чуть разной длины.
Что в итоге
Ролик стал артефактом сборки. Его можно поревьюить диффом, а через месяц после редизайна просто переснять. Бонусом так же собирается пресс-кит: press-shots.py прогоняет раскадровку и вырезает кадры по расписанию из секции press. Нужен свежий скриншот после редизайна облачка - перезапускаю, а не позирую с котом вручную.
Цена: около 1800 строк Swift под #if DEBUG, скрипт на 930 строк и десяток историй с испорченными дублями, каждая из которых превратилась в проверку. Для пет-проекта перебор? Наверное 🤡 Зато переснять ролик теперь стоит одной команды, а не вечера.
Сам Mewmori закрытый, и съёмочного режима в открытом виде нет. Зато открыт стартер, вырезанный из того же кота: слоёный риг на SpriteKit (упрощённый вариант того, у которого отваливалась голова), локальная 2B-модель на llama.cpp и OCR экрана. Лицензия Apache 2.0: https://github.com/BarredEwe/mewmori-cat-starter
А как вы делаете промо для своих приложений? Руками, через Screen Studio, что-то автоматизировали?
Кот, если интересно посмотреть на актёра: https://mewmori.com/?src=habr 🐾
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.