Inquirer EntertainmentGrace Lee reveals pregnancy with ‘miracle baby’ at 43Daily MaverickWHAT’S COOKING: A steak salad with baby potatoes, broccoli, green beans, tomatoes and two dressingsPunchDSS arraigns man over fake Tinubu letter to US PresidentThe Jerusalem PostChannel 14 reporter verbally assaulted, harassed while covering voting in HaifaBollywood HungamaSaif Ali Khan and Vidya Balan reunite for Ritesh Batra’s Long Years Ago; Mélanie Laurent joins castESPNTransfer rumors, news: Real Madrid eye Liverpool's Alexis Mac AllisterInquirerKidapawan Diocese mourns desecration of parish churchUOLSignos: confira seu horóscopo desta terça (27)ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioKhaosod EnglishIsraelis vote in a tense national election seen as a referendum on NetanyahuBBC NewsTruce begins in Ukraine city where residents ate weeds to surviveObservador DesportoAs notícias das 9h
The Daily Newsstand · Free, Always
Tuesday, October 27, 2026

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

Translate
Пиксельный кот Mewmori на съёмочной площадке с камерой, хлопушкой и JSON-раскадровкой

Пиксельный кот Mewmori на съёмочной площадке с камерой, хлопушкой и JSON-раскадровкой

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

Актёр - это 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 августа.

Настоящие кадры: реакция кота на упавший и на зелёный билд

Настоящие кадры: реакция кота на упавший и на зелёный билд

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

Пайплайн от storyboard.json через запись Debug-сборки и report.json до вертикального mp4

Пайплайн от storyboard.json через запись Debug-сборки и report.json до вертикального mp4

Кот играет сценарий, но только в Debug

Весь съёмочный режим лежит в Mewmori/Sources/Common/Promo/: 5 файлов, около 1800 строк, и каждый начинается с #if DEBUG. В релиз это не попадает вообще.

Дубль - это обычный сценарий кота, только его нельзя перебить. У меня поведение кота крутится в акторе ScenarioEngine, и любой сценарий можно перебить более важным (кот проголодался, например). Съёмочный перебить нельзя, иначе посреди дубля кот уходит есть.

Шагов в формате немного: say, mood, clip, effect, reaction, look (скин и одежда), move (walk, jump, teleport), wait, cursor. Координаты в долях экрана от нижнего левого угла, а не в пикселях. Так одна раскадровка работает на любом разрешении.

Синхронизация через файлы

Скрипт и приложение общаются максимально тупо, через файлы в promo/.work/:

  1. Кот запускается и ждёт файл-отмашку cue.

  2. Скрипт стартует screencapture и кладёт cue.

  3. Кот отвечает файлом started и играет дубль.

  4. В конце пишет report.json с таймингами и своей рамкой.

  5. Скрипт останавливает запись, обрезает, кадрирует.

Скрипт и кот синхронизируют запись через файлы cue, started и report.json

Скрипт и кот синхронизируют запись через файлы 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 пикселей на Retina

Объединение окон кота и вертикальный кроп 540 точек, который даёт 1080 пикселей на Retina

Почему 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 🐾

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.

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