javascript

Месть за голосовые: генерирую видео с печатающими руками прямо в браузере

  • понедельник, 16 ноября 2026 г. в 00:00:05
https://habr.com/ru/articles/1108048/

У меня есть друзья, которые присылают голосовые на три минуты. Содержание обычно умещается в одно предложение, но чтобы его достать, нужно прослушать «короче, слушай, тут такое дело…». Голосовое не пробежишь глазами, на созвоне его не включишь, а ночью рядом со спящим человеком тем более.

В какой-то момент я решил ответить им так же неудобно. Получился 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.

И чтобы без обид: голосовые — нормальная штука, когда ты за рулём или руки заняты. Шутка про другое — про две минуты «короче, слушай» вместо одной строчки. И отправлять такое стоит тем, кто посмеётся вместе с вами.

Если у вас что-то не заработало, напишите в комментариях телефон и браузер. И какой режим добавить следующим, тоже пишите: перекуры появились именно так.