У меня есть друзья, которые присылают голосовые на три минуты. Содержание обычно умещается в одно предложение, но чтобы его достать, нужно прослушать «короче, слушай, тут такое дело…». Голосовое не пробежишь глазами, на созвоне его не включишь, а ночью рядом со спящим человеком тем более.
В какой-то момент я решил ответить им так же неудобно. Получился pleasejusttext.me: пишешь ответ текстом, а на выходе MP4, где мультяшные руки не спеша набирают его на телефоне. Меня заставили слушать, пусть теперь смотрят.

Набирать можно по-разному: обычно, с опечатками, с маникюром (длинные ногти, медленнее и иногда мимо) или как дед — одним дрожащим пальцем, подолгу зависая над каждой буквой. А на второй день после запуска в комментариях попросили «сделай большие паузы, будто он курит». Теперь есть и такой режим: посреди фразы правая рука уходит из кадра и возвращается то с латте, то с сигаретой.
Сразу решил, что сервера для рендера не будет. Видео рисуется и кодируется на телефоне пользователя: ничего не загружается, нет очереди, и если вдруг придёт много людей, платить мне будет не за что. Ниже про то, как это устроено.
Общая схема
текст → таймлайн (какая клавиша нажата в какой момент) → renderFrame(t) на canvas → WebCodecs, H.264 → MP4 → системное «Поделиться»
Стек обычный: Vite, TypeScript, Vitest. Кодированием и упаковкой в MP4 занимается Mediabunny. Для тестов в Node используется @napi-rs/canvas. Код я писал вместе с ИИ-ассистентом, и первая версия вместе с покупкой домена и настройкой деплоя заняла примерно вечер. Архитектура из следующего раздела тут сильно помогла: любой результат проверяется рендером одного кадра.
Таймлайн
Сначала текст превращается в список событий: «нажать h в 1.23 с», «shift в 2.10 с», «зависнуть над к в 4.5 с» (это дед ищет букву), в конце «нажать отправить».
Резать текст по length нельзя: "👍🏽".length равно 4, а для человека это одно нажатие. Поэтому текст делится на графемы через Intl.Segmenter:
const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); export const graphemes = (s: string) => Array.from(seg.segment(s), (x) => x.segment);
Этой же функцией считается счётчик символов под полем ввода. Поэтому maxlength у textarea я не ставил: браузер считает его в UTF-16, а не в том, что видит пользователь.
Сам таймлайн строит маленькая модель iOS-клавиатуры. В ней есть QWERTY, ЙЦУКЕН, испанская раскладка и AZERTY, страницы 123 и #+=, shift и caps lock (двойной тап, если подряд идут две заглавные и больше). Если набрать Hi! Привет, на экране видно, как рука жмёт глобус и клавиатура переключается на русскую. Это, пожалуй, самая бесполезная и самая любимая моя деталь.
Все задержки и опечатки берутся из генератора с сидом (mulberry32), поэтому одинаковый ввод всегда даёт одинаковое видео. Режимы отличаются только профилем:
const PROFILES: Record<Mode, Profile> = { normal: { key: [0.09, 0.2], typo: 0, hover: 0 }, grandpa: { key: [0.5, 1.1], typo: 0, hover: 0.6 }, typos: { key: [0.07, 0.15], typo: 0.12, hover: 0 }, manicure: { key: [0.12, 0.26], typo: 0.05, hover: 0 }, breaks: { key: [0.09, 0.2], typo: 0, hover: 0 }, // плюс паузы между словами, смены раскладки и т. д. };
Видео ограничено минутой. У деда лимит 30 символов: иначе его ответ получается длиннее голосового, на которое он отвечает.
Кадр как функция от времени
Это решение я бы повторил в любом проекте с экспортом анимации. Кадр зависит только от t. Между кадрами ничего не хранится: для каждого момента рендерер заново выясняет, какие буквы уже набраны, какая клавиша нажата и где каждый палец.
Пальцы двигаются по опорным точкам вида «быть на клавише X ровно в момент T», между ними позиция интерполируется с easing. Дрожь деда — две синусоиды сверху:
tip = { x: tip.x + 1.6 * Math.sin(t * 13.1) + 1.1 * Math.sin(t * 7.3), y: tip.y + 1.4 * Math.sin(t * 11.7 + 1), };
Картинок в проекте нет вообще: клавиатура, пальцы, ногти, кружка, сигарета и дым рисуются путями Canvas 2D.
Раз функция чистая и не трогает DOM, она без изменений работает в Node. Тесты рендерят конкретные кадры, отдельный скрипт выгружает PNG для визуальной проверки, а OG-картинка сайта — это просто кадр из настоящего видео.
Как появились перекуры
Когда попросили паузы «будто курит», перекраивать анимацию не пришлось.
Перерыв — это просто интервал в таймлайне, рядом с событиями:
interface Break { kind: 'coffee' | 'smoke'; t0: number; t1: number; }
Генератор вставляет его после какого-нибудь слова (ближе к пробелу, чтобы рука не уходила посреди слова), и кофе с сигаретой чередуются. Внутри перерыва поза руки зависит от прогресса p от 0 до 1, а не от секунд:
const p = (t - brk.t0) / (brk.t1 - brk.t0); // 0.00–0.10 рука уходит за кадр // 0.13–0.25 возвращается с кружкой или сигаретой // 0.36–0.46 подносит ко рту // 0.56–0.66 опускает // 0.74–0.82 снова уходит // 0.86–1.00 возвращается пустой и печатает дальше
Если видео не влезает в минуту и таймлайн целиком сжимается, перерывы сжимаются вместе с ним, и хореографию это не ломает.
Самое интересное было в рисовании. В первой версии рука поднимала кружку к верхнему краю экрана и протаскивала её через весь кадр. Но камера смотрит на телефон сверху, а рот находится где-то под нижним краем. Теперь рука уезжает вниз и увеличивается примерно в 1,6 раза, как будто приближается к лицу, а после затяжки снизу поднимается дым.
Сигарета сначала была палкой, приклеенной к пальцу. Между пальцами она с такого ракурса проходит над одним пальцем и под другим, так что всё решил порядок слоёв:
const SMOKE = [ FOREARM, PINKY, RING, 'hand', THUMB, MIDDLE, 'cig', INDEX, // сигарета между средним и указательным ];
С кофе забавно вышло: «правильная» розетта, как у бариста, на маленькой картинке читалась как папоротник или ёлочка. Победило самое банальное: белая чашка, латте и сердечко, повёрнутое обратно на угол руки, чтобы не лежало на боку.
Одна строчка, которая сломала всё остальное
Перед выкаткой я сравнил старую и новую версии: сгенерировал таймлайны и кадры старых режимов на нескольких текстах и сравнил хеши. Отличалось почти всё, хотя старые режимы я не трогал. Виновата была эта строчка:
let nextKind = rng() < 0.5 ? 'coffee' : 'smoke';
Она вызывала генератор во всех режимах, сдвигала последовательность случайных чисел, и у всех менялись тайминги. Видео при этом оставались вполне правдоподобными, просто другими, так что глазами такое не заметить. Исправление:
let nextKind = breakAt.length && rng() < 0.5 ? 'coffee' : 'smoke';
После этого из 36 сравнений не отличалось ни одно. Если у вас детерминированная генерация, такое сравнение со старой версией дешёвое и очень полезное.
Кодирование: WebCodecs через Mediabunny
Основной цикл выглядит так:
const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target: new BufferTarget(), }); const source = new CanvasSource(canvas, { codec: 'avc', bitrate: QUALITY_MEDIUM, keyFrameInterval: 2, }); output.addVideoTrack(source, { frameRate: fps }); await output.start(); for (let i = 0; i < frameCount; i++) { if (signal?.aborted) { await output.cancel(); throw new DOMException('Render cancelled', 'AbortError'); } const t = i * dt; renderFrame(ctx, timeline, t, opts); await source.add(t, dt); // энкодер может притормозить цикл onProgress?.((i + 1) / frameCount); } await output.finalize();
Самое важное здесь — await на каждом кадре. Так энкодер сам притормаживает цикл, и кадры не копятся в памяти, что на телефонах особенно важно.
Ещё пара моментов. Поддержку лучше проверить заранее через canEncodeVideo('avc', { width, height }): если устройство не умеет H.264 в таком размере, человек увидит понятное сообщение, а не битый файл. H.264 в MP4 с fastStart выбран потому, что так видео показывается прямо в чате и в Telegram, и в WhatsApp. Отмена сделана через обычный AbortSignal: закрыл окно, и рендер остановился.
Почему не captureStream() и MediaRecorder? Он пишет в реальном времени, то есть 40 секунд видео пишутся 40 секунд. И формат зависит от браузера: часто это WebM, который мессенджеры показывают не везде. WebCodecs работает быстрее реального времени и всегда отдаёт MP4. MediaRecorder я оставил в планах как запасной вариант для устройств без WebCodecs.
На выходе 720×720 при 30 fps.
Мелочи, о которых полезно знать
Шрифты. Canvas рисует тем шрифтом, который загружен прямо сейчас. Если Inter ещё не успел подгрузиться, первые кадры получаются системным шрифтом, а потом текст «прыгает». Поэтому начертания загружаются явно через FontFace (латиница и кириллица отдельно) и ждать их перед рендером.
Отправка. На телефоне удобнее всего системное меню «Поделиться»:
const file = new File([blob], 'reply.mp4', { type: 'video/mp4' }); if (navigator.canShare?.({ files: [file] })) { await navigator.share({ files: [file] }); } else { // ссылка на скачивание }
Только share() можно вызвать лишь в ответ на действие пользователя, а рендер идёт несколько секунд, и за это время браузер уже «забывает» про нажатие. Поэтому кнопок две: «Сделать видео», а когда оно готово — «Отправить».
Встроенные браузеры. Instagram, TikTok и Facebook открывают ссылки в своём браузере, а он часто не умеет делиться файлами. Сайт их узнаёт и предлагает открыть страницу в нормальном браузере, на Android сразу ссылкой в Chrome.
Вес. Рендерер, Mediabunny и шрифты грузятся лениво, так что первая загрузка весит около 11 КБ в gzip. Модуль видео подтягивается заранее, пока браузер простаивает или когда человек ставит курсор в поле ввода.
Хостинг — статика на Cloudflare и один маленький Worker, который считает анонимные события: сколько видео сделали, сколько отправили, сколько было ошибок. Cookies нет, тексты на сервер не уходят. Обходится всё это чуть дороже домена.
Что дальше
Главная дыра — устройства без WebCodecs H.264: сейчас они видят «не поддерживается», и запасной вариант через MediaRecorder покрыл бы часть из них. И, конечно, я проверил всё на куда меньшем числе реальных телефонов, чем хотелось бы.
Попробовать можно здесь: pleasejusttext.me. Бесплатно и без регистрации, интерфейс на русском, английском, испанском и французском. Исходники под MIT: github.com/lgordey/typing-reply.
И чтобы без обид: голосовые — нормальная штука, когда ты за рулём или руки заняты. Шутка про другое — про две минуты «короче, слушай» вместо одной строчки. И отправлять такое стоит тем, кто посмеётся вместе с вами.
Если у вас что-то не заработало, напишите в комментариях телефон и браузер. И какой режим добавить следующим, тоже пишите: перекуры появились именно так.
