В Telegram мне попался пост про Snap — расширение для Google Meet от @deniza‑png. Перед созвоном снимаешь пустую комнату, посреди звонка щёлкаешь пальцами и исчезаешь.

Я сижу в Яндекс.Телемосте а не в Meet, поэтому решил сделать то же самое под него. А раз уж всё равно залезаю в видеопоток, туда же можно положить и остальное, чего мне на созвонах не хватало: фон, маски, стикеры, субтитры с переводом, календарь и трекер рядом со звонком. Вообще в целом постарался засунуть туда по максимуму.
Так получилось расширение Яндекс.Телепульт.
К Яндексу я отношения не имею, просто пишу под то, чем пользуюсь каждый день. Профиль автора Snap на Хабре я не нашёл, поэтому отмечаю её по нику с GitHub, если она тут под другим именем — напишите в комментариях, поправлю.

пульт на звонке
пульт на звонке

Перехват камеры

Телемост берёт видео с камеры через обычный getUserMedia и отдаёт полученный MediaStream в WebRTC. Откуда взялся видеотрек, ему без разницы. Поэтому я патчу getUserMedia раньше, чем его вызовет страница: настоящую камеру открываю сам, а Телемосту возвращаю свой трек, который рисую на canvas.

const origGUM = MediaDevices.prototype.getUserMedia;

MediaDevices.prototype.getUserMedia = async function (constraints) {
  const real = await origGUM.apply(this, arguments);
  try {
    if (constraints?.video) return await wrapCameraStream(real);
    if (constraints?.audio) return await wrapAudioStream(real);
  } catch (e) {
    console.warn("wrap failed", e);
  }
  return real; // если обёртка упала, отдаём оригинал, звонок важнее эффектов
};

Внутри wrapCameraStream трек камеры уходит в скрытый video, кадры рисуются на canvas, а наружу отдаётся canvas.captureStream. Телемост заглядывает в getSettings и label трека, поэтому их я подменяю данными настоящей камеры.

async function wrapCameraStream(real) {
  const camTrack = real.getVideoTracks()[0];
  const video = document.createElement("video");
  video.muted = true;
  video.srcObject = new MediaStream([camTrack]);
  await video.play();

  const pipe = new Pipeline(video);
  pipe.start();

  const outTrack = pipe.canvas.captureStream(30).getVideoTracks()[0];
  outTrack.getSettings = () => ({ ...camTrack.getSettings(), width: pipe.W, height: pipe.H });
  Object.defineProperty(outTrack, "label", { value: camTrack.label });
  return new MediaStream([outTrack, ...real.getAudioTracks()]);
}

Отсюда главное свойство всех фич ниже: всё, что я рисую на этом canvas, уезжает собеседникам как часть обычного видео. Всё это попадает в запись встречи и видно на моем превью другим, даже без установленного расширения на их стороне.

Рендер идёт по requestAnimationFrame, но я режу его примерно до 30 fps и пропускаю тик, если прошлый кадр ещё не дорисован, иначе на слабом ноутбуке кадры копятся и видео отстаёт от звука. Микрофон оборачивается отдельно, потому что Телемост часто запрашивает его вторым вызовом getUserMedia, без видео.

MediaPipe

Для лица, рук и сегментации я использую Google MediaPipe Tasks Vision, модели работают в браузере на WebAssembly, по возможности с GPU:

  • Face Landmarker — 478 точек лица, на них держатся маски и размытие лица;

  • Hand Landmarker — 21 точка на кисть, это жесты, щелчок и рисование;

  • Image Segmenter с selfie segmenter — маска человека для фонов и блюра.

Модели лежат в пакете расширения: лицо около 3.6 МБ, руки около 7.5 МБ, сегментатор 244 КБ, весь пакет около 20 МБ. Установка из‑за этого чуть дольше, зато не нужно качать десятки мегабайт с CDN в момент, когда человек уже в звонке и включил фон, а на слабом интернете это заметная пауза. Есть и формальная причина: Chrome Web Store не пропускает расширения, которые подгружают исполняемый код со сторонних адресов, а MediaPipe тянет за собой WASM.

const vision = await FilesetResolver.forVisionTasks(
  chrome.runtime.getURL("vendor/tasks-vision/wasm")
);

const face = await FaceLandmarker.createFromOptions(vision, {
  baseOptions: {
    modelAssetPath: chrome.runtime.getURL("vendor/models/face_landmarker.task"),
    delegate: "GPU", // если GPU не поднялся, пересоздаю с "CPU"
  },
  runningMode: "VIDEO",
  numFaces: 1,
});

Грузится всё лениво: пока не включена фича, которой нужна модель, модель не загружается. Инференс живёт в отдельной скрытой странице расширения, страница звонка шлёт туда уменьшенный кадр как ImageBitmap и получает обратно точки и маску. Кадр перед отправкой ужимается до 256–480 px по ширине, в зависимости от набора моделей, — для поиска лица больше не нужно, а по скорости разница большая.

Первая версия отправляла на инференс каждый кадр. Когда модель не успевала, сообщения копились в очереди, и через минуту маска отставала от лица секунд на пять. Сейчас кадр, пришедший пока модель занята, просто выбрасывается, и у каждой комбинации моделей свой минимальный интервал: одни руки можно гонять почти каждый кадр, сегментацию вместе с руками — не чаще раза в 100 мс.

async process(video, needSeg, needFace, needHands) {
  if (this.busy) return; // модель занята - кадр дропаем, очередь не копим
  if (performance.now() - this.lastSend < this.minGap(needSeg, needFace, needHands)) return;
  this.busy = true;
  try {
    const bitmap = await createImageBitmap(this.downscale(video));
    const res = await this.sendToModels({ bitmap, needSeg, needFace, needHands });
    this.apply(res);
  } finally {
    this.busy = false;
  }
}

Точки лица от кадра к кадру дрожат, поэтому новые координаты я смешиваю со старыми примерно пополам. Задержка почти не видна, а маска перестаёт трястись.

Исчезновение

Сценарий как у Snap: включил камеру, вышел из кадра, нажал «Снять комнату», вернулся. Дальше по кнопке «Исчезнуть» или по щелчку пальцами человек исчезает, и в превью уходит пустая комната. «Вернуться» проигрывает анимацию в обратную сторону.

исчезновение - Танос - старый интерфейс
исчезновение — Танос — старый интерфейс
текущий интерфейс
текущий интерфейс

«Снять комнату» берёт не один кадр, а 14 кадров с шагом 40 мс и усредняет их попиксельно. Вебкамеры шумят, особенно в полумраке, и шум одного кадра потом вылезает в маске ложными пятнами. Готовый эталон сжимается в JPEG и сохраняется в chrome.storage.local, так что после перезагрузки вкладки или на следующем созвоне комнату не нужно снимать заново, если камера стоит на том же месте.

Сегментация тут не нужна: если есть фото пустой комнаты, человек — это всё, что от него отличается. Живой кадр и эталон уменьшаются до 480 px по ширине, и для каждого пикселя считается сумма модулей разниц по RGB. Больше порога — пиксель считается человеком.

const DIFF_T = 44;

for (let i = 0; i < n; i++) {
  const j = i * 4;
  const diff =
    Math.abs(live[j]     - bg[j]     - o0) +
    Math.abs(live[j + 1] - bg[j + 1] - o1) +
    Math.abs(live[j + 2] - bg[j + 2] - o2);
  bin[i] = diff > DIFF_T ? 255 : 0;
}

o0, o1, o2 — поправка на автоэкспозицию и баланс белого. Стоит сесть в кадр в тёмной футболке, камера подтягивает яркость всей картинки, и без поправки вся комната начинает отличаться от эталона. Сдвиг считается в два прохода: сначала средняя разница по всему кадру, потом она же, но только по пикселям, которые и так близки к эталону, то есть по фону. Её и вычитаю.

Сырая маска после порога рваная, поэтому дальше её чищу:

  1. Дважды размываю и снова отсекаю по порогу — уходит шум, края становятся ровнее.

  2. Оставляю только самое большое пятно, остальное считаю мусором.

  3. Заливаю фон от краёв кадра. Куда заливка не дошла — это человек, так закрываются дыры на футболке под цвет стены.

Эффект Таноса устроен так: каждому пикселю заранее выдаётся порог, который зависит от координаты по x и немного от случайности, а прогресс анимации идёт от 0 до 1. Пиксель пропадает, когда прогресс перешёл его порог, поэтому человек рассыпается слева направо с рваным краем. Часть пропавших на кадре пикселей превращается в частицы пепла их же цвета, а под человеком рисуется эталон комнаты.

for (let y = 0; y < HH; y++)
  for (let x = 0; x < WW; x++)
    thr[y * WW + x] = 0.55 * (x / WW) + 0.45 * Math.random();

// на каждом кадре
alpha[i] = mask[i] * smooth((thr[i] - p) / BAND + 0.5);

Остальные переходы — растворение, глитч, пиксели, воронка, дым, осколки, выключение ЭЛТ, портал, плитки, сгорание, таяние, взрыв — работают по той же замороженной маске и отличаются только тем, какой пиксель и когда пропадает.

Щелчок распознаётся по видео, микрофон не участвует в отличие от Snap. Я храню историю расстояния между кончиком большого пальца и ближайшим из кончиков указательного и среднего за последние 300 мс. Щелчок — это пальцы сведены, а потом быстро разошлись, поэтому ищу в окне минимум, после которого расстояние выросло с нужной скоростью. После срабатывания 1.2 секунды кулдауна, чтобы один щелчок не запустил исчезновение и сразу возвращение.

function detectSnap(history, now) {
  const s = history.filter((p) => now - p.t <= 300);
  const min = minBy(s, "d");
  const max = maxBy(s, "d");
  if (!(min.d < 0.085 && max.d > 0.095 && max.t > min.t + 20)) return false;
  const after = s.filter((p) => p.t >= min.t);
  const first = after[0], last = after[after.length - 1];
  const speed = (last.d - first.d) / Math.max(16, last.t - first.t);
  return speed > 0.00028 && last.d - min.d > 0.04;
}

Рядом есть еще микрофича: «Заморозить кадр»: в звонок уходит последний отрисованный кадр, пока не нажмёшь ещё раз. В будущем есть идея расширить этот функционал чтобы вместо статичной картинки была последовательность из N кадров как live‑фото на айфоне: в таком случае на долгих и длинных встречах никто не заподозрит что вы куда‑то отошли.

Фон и размытие

Здесь эталона комнаты нет, так что без сегментатора не обойтись. Он отдаёт confidence mask, и я перевожу её в альфу с мягким переходом: ниже 0.35 — фон, выше 0.7 — человек, между ними линейный край, чтобы волосы не выглядели вырезанными.

for (let i = 0; i < out.length; i++) {
  let a = person[i];
  if (inverted) a = 1 - a;
  out[i] = a < 0.35 ? 0 : a > 0.7 ? 255 : (((a - 0.35) / 0.35) * 255) | 0;
}

Маска у сегментатора маленькая, при растяжении на 720p вокруг головы появлялся светлый ореол. Поэтому перед наложением я растягиваю её с blur в 1 px и ещё раз прохожу порогом. Дальше обычный композитинг: фон, поверх кадр камеры через destination-in с маской.

фоны
фоны

Фонов около двадцати, плюс свой файл. Анимированные — матрица, снег, дождь, космос, светлячки и остальные — рисуются процедурно на том же canvas, поэтому в пакете почти ничего не весят.

Маски и размытие лица

Маска — это несколько PNG‑слоёв: уши, нос, язык. Для каждого слоя записаны две опорные точки на картинке и две точки лица из Face Landmarker. По двум парам точек считаю similarity transform и рисую слой так, чтобы точки совпали. Уши наклоняются вместе с головой и уменьшаются, когда отодвигаешься от камеры.

function drawSimilarity(ctx, img, srcA, srcB, dstA, dstB) {
  const scale = dist(dstA, dstB) / dist(srcA, srcB);
  const angle =
    Math.atan2(dstB.y - dstA.y, dstB.x - dstA.x) -
    Math.atan2(srcB.y - srcA.y, srcB.x - srcA.x);
  ctx.save();
  ctx.translate(dstA.x, dstA.y);
  ctx.rotate(angle);
  ctx.scale(scale, scale);
  ctx.drawImage(img, -srcA.x, -srcA.y);
  ctx.restore();
}

Уши слегка покачиваются, а язык у собаки показывается, только когда открыт рот — это расстояние между точками верхней и нижней губы.

маски
маски

Анонимность закрывает только лицо, а не весь кадр. У Face Landmarker есть контур лица из 36 точек, по ним я строю путь, делаю clip и внутри рисую либо размытый кадр, либо пиксели. Пиксели — классика: уменьшаю кусок лица в 16 раз и растягиваю обратно с imageSmoothingEnabled = false.

анонимность
анонимность
ctx.save();
ctx.beginPath();
ctx.moveTo(oval[0].x, oval[0].y);
for (const p of oval) ctx.lineTo(p.x, p.y);
ctx.closePath();
ctx.clip();

if (mode === "blur") {
  ctx.filter = "blur(16px)";
  ctx.drawImage(video, 0, 0, W, H);
} else {
  small.drawImage(video, x, y, w, h, 0, 0, w / 16, h / 16);
  ctx.imageSmoothingEnabled = false;
  ctx.drawImage(small.canvas, 0, 0, w / 16, h / 16, x, y, w, h);
}
ctx.restore();

Контур едет за лицом, поэтому можно ходить по комнате, а руки и фон остаются обычными.

Жесты и рисование пальцем

Чтобы понять, вытянут ли палец, хватило простого правила: кончик пальца дальше от запястья, чем его средний сустав, с запасом 12%. По набору вытянутых пальцев определяется жест.

const fingerOut = (lm, tip, pip) =>
  dist(lm[tip], lm[WRIST]) > dist(lm[pip], lm[WRIST]) * 1.12;

function classifyHand(lm) {
  const ix = fingerOut(lm, 8, 6), md = fingerOut(lm, 12, 10);
  const rg = fingerOut(lm, 16, 14), pk = fingerOut(lm, 20, 18);
  const thumb = dist(lm[4], lm[WRIST]) > dist(lm[3], lm[WRIST]) * 1.15;
  const count = [ix, md, rg, pk].filter(Boolean).length;

  if (count >= 4) return "palm";
  if (thumb && count <= 1) return "thumbsup";
  if (ix && md && !rg && !pk) return "victory";
  if (ix && !md && !rg && !pk) return "point";
  return null;
}

Жест засчитывается, только если продержался два распознавания подряд, и между срабатываниями 700 мс, иначе любое движение рукой у лица что‑нибудь включает. Ладонь и большой палец отправляют в кадр реакции ✋ и ?, указательный включает и выключает микрофон, victory показывает и прячет картинку в углу кадра.

рисование пальцем
рисование пальцем

Рисование берёт кончик указательного пальца (точка 8) и пишет его координаты в след. Координаты сглаживаю экспоненциально, чтобы линия была ровной, но не отставала от пальца. Каждый сегмент гаснет за 2.8 секунды, альфа падает по квадрату, так что линия держится яркой и быстро исчезает в конце. Стирать ничего не нужно: обвёл что‑то на доске за спиной, и через три секунды линии нет.

Субтитры с переводом

Говорю по‑русски, а в кадре снизу идут субтитры на английском, немецком или японском. Это два этапа: распознавание речи и перевод, а готовая строка рисуется в нижней части того же canvas.

субтитры с переводом
субтитры с переводом

Распознавание

Транскрибацию делает Web Speech API, в Chrome это webkitSpeechRecognition. Своей модели в расширении нет: Chrome сам слушает микрофон, отправляет звук в свой сервис распознавания и возвращает текст. Значит, нужен интернет, и качество зависит от языка и сети. Зато не надо класть в пакет ещё одну тяжёлую модель рядом с MediaPipe, а для субтитров на созвоне качества хватает. Поддержка браузерами есть на caniuse, на практике это Chrome и Яндекс Браузер.

const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
const rec = new SR();
rec.lang = "ru-RU";
rec.continuous = true;     // не останавливаться после первой фразы
rec.interimResults = true; // отдавать черновики по ходу речи

rec.onresult = (ev) => {
  for (let i = ev.resultIndex; i < ev.results.length; i++) {
    const text = ev.results[i][0].transcript.trim();
    pushCaption(text, ev.results[i].isFinal);
  }
};

Без continuous распознавание останавливается на первой паузе. interimResults даёт черновики: пока isFinal false, Chrome ещё может поправить текст, но показывать его можно сразу, и строка появляется почти одновременно с речью. Цикл начинается с ev.resultIndex, потому что в results лежит вся сессия, и с нуля на длинном созвоне пришлось бы каждый раз перебирать фразы получасовой давности.

Даже с continuous: true Chrome периодически сам завершает сессию и шлёт onend — после паузы в речи или сетевого сбоя. Если на это не реагировать, через несколько минут субтитры молча пропадают. Поэтому на onend перезапускаю распознавание с небольшой задержкой. lang у запущенного инстанса не меняется, поэтому при смене языка пересоздаю его целиком.

rec.onend = () => {
  if (!needRecognition() || restarting) return;
  restarting = true;
  setTimeout(() => {
    restarting = false;
    try { rec.start(); } catch (_) {}
  }, 280);
};

function changeLanguage() {
  try { rec.stop(); } catch (_) {}
  try { rec.abort(); } catch (_) {}
  rec = new SR();
  wireRecognition(rec); // lang, continuous, onresult, onend
  rec.start();
}

Перевод

Переводит встроенный Translator API. Модель для пары языков Chrome скачивает при первом использовании, дальше перевод идёт локально. Сторонних сервисов и API‑ключей в расширении нет, а если API недоступен, в кадре остаётся исходный текст.

async function translateText(text, source, target) {
  if (!text || source === target) return text;
  const key = `${source}>${target}:${text}`;
  if (cache.has(key)) return cache.get(key);
  try {
    const translator = await Translator.create({ sourceLanguage: source, targetLanguage: target });
    const out = await translator.translate(text);
    if (out) {
      cache.set(key, out); // храню последние 120 фраз
      return out;
    }
  } catch (_) {}
  return text;
}

С черновиками две проблемы. Первая — их слишком много, несколько в секунду, поэтому перевожу не чаще раза в 450 мс, а пока перевод считается, показываю исходник с многоточием. Вторая — перевод асинхронный, и ответ на старый черновик может прийти позже ответа на новый, тогда строка в кадре откатывается назад. Лечится счётчиком поколений: если за время перевода ушёл запрос новее, результат выбрасывается.

async function pushCaption(raw, isFinal) {
  if (!isFinal) {
    if (performance.now() - lastInterimAt < 450) return;
    lastInterimAt = performance.now();
    captions.interim = "… " + raw.slice(-160);
    redraw();
  }

  const gen = ++translateGen;
  const shown = await translateText(raw, captions.sourceLang, captions.targetLang);
  if (gen !== translateGen) return; // пока переводили, пришло что-то новее

  if (isFinal) {
    captions.text = shown.slice(-180);
    captions.interim = "";
  } else {
    captions.interim = shown.slice(-180);
  }
  redraw();
}

Языков сейчас семь: русский, английский, немецкий, французский, испанский, японский и китайский. Если выбрать одинаковые «Говорю на» и «Субтитры на», получатся обычные субтитры без перевода. Минус такого подхода — собеседник не может выключить субтитры у себя, они часть видео. А может и плюс.

Была идея развивать это в дальнейшем в сторону хранения всей беседы с таймингами и текстами всей встречи которую затем через удаленную llm конвертировать в итоги/тезисы встречи со спикерами. Но оказывается такая фича у Телемоста уже есть, и мало того — платная. Поэтому не стал, ну вы меня понимаете.

Голос

Микрофон идёт через граф Web Audio: клон аудиотрека, MediaStreamSource, цепочка эффектов, MediaStreamDestination, и Телемосту отдаётся поток из destination. Трек клонирую, потому что Телемост сам переключает enabled у своего трека, и без клона граф иногда замолкал.

Эффекты собраны из стандартных узлов:

  • робот — узкий bandpass на 1100 Гц, WaveShaper и подъём на 1800 Гц;

  • телефон — полоса 300–3400 Гц, как у старой телефонной линии, плюс лёгкий перегруз;

  • эхо — DelayNode на 200 мс с обратной связью;

  • гелий и глубокий — сдвиг высоты тона.

// телефон
const hp = ac.createBiquadFilter();
hp.type = "highpass";
hp.frequency.value = 300;
const lp = ac.createBiquadFilter();
lp.type = "lowpass";
lp.frequency.value = 3400;
const crunch = ac.createWaveShaper();
crunch.curve = makeDistortionCurve(28);
source.connect(hp).connect(lp).connect(crunch).connect(master);

Готового pitch shift в Web Audio нет, а эквалайзером гелий не изобразить, получается просто плохой микрофон. Я взял приём из Audio Input Effects Криса Уилсона: две линии задержки, время задержки которых меняется по пиле. Когда задержка равномерно уменьшается, звук проигрывается чуть быстрее и тон растёт, когда увеличивается — падает. Получилось что получилось.

Счётчик мата и слов‑паразитов

Работает на том же распознавании, что и субтитры. Фразу привожу к нижнему регистру, ё к е, убираю пунктуацию и ищу слова из двух списков: мат и паразиты вроде «короче», «как бы», «на самом деле». Длинные фразы ищутся первыми, чтобы «как бы» не посчиталось дважды, а слова до четырёх букв — только целиком, иначе они находятся внутри приличных слов.

счетчик мата
счетчик мата

Без наушников микрофон слышит собеседников из колонок, и счётчик считал чужой мат. Теперь слово засчитывается, только если в этот момент по уровню сигнала с моего микрофона видно, что говорю я. Счёт можно держать только у себя или показать в кадре всем.

Стикеры и реакции

Стикеры — короткие webm с альфа‑каналом, плюс можно загрузить свой до 2.5 МБ. Стикер около трёх секунд поднимается по кадру и гаснет, одновременно на экране не больше восьми стикеров и реакций.

стикеры
стикеры

Каждый стикер — скрытый зацикленный video, текущий кадр которого рисуется на выходной canvas. Примерно 15 раз в секунду я дополнительно снимаю с него ImageBitmap как запасной кадр: пока видео не прогрузилось или браузер его придержал, рисуется последний bitmap, и стикер не мигает пустым квадратом.

reactions.push({
  kind: "sticker",
  video,       // webm с альфой
  bitmap,      // запасной кадр
  life: 3200,
  x0: 0.28 + Math.random() * 0.44,
  y0: 0.55 + Math.random() * 0.22,
});

Свои стикеры лежат в IndexedDB и переживут перезагрузку страницы.

Остальное на том же canvas

Статус — короткая плашка с текстом под подбородком, привязана к точке подбородка из Face Landmarker и ездит вместе с лицом.

AFK на 1, 3 или 5 минут — плашка «отошёл» с обратным отсчётом.

Бегущая строка внизу кадра. Чтобы не было рывка на стыке, текст рисуется несколько раз подряд со сдвигом ровно на ширину одной копии.

Картинка в углу — схема или QR‑код в кадре без демонстрации экрана, жестом victory показывается и прячется.

Ретро — цветные карточки поверх своего превью. Размер шрифта на карточке подбирается бинарным поиском, чтобы текст любой длины влезал целиком. Для короткого ретро втроём это быстрее, чем звать всех на отдельную доску.

ретро карточки/записки
ретро карточки/записки

И колонки рядом со звонком: Яндекс Музыка, Календарь 360 и Трекер по своим ссылкам.

Хотелки Яндекса

Самое смешное — уже после релиза я открыл страницу пожеланий Яндекс 360 по Телемосту и отсортировал по голосам, всё в статусе «Запланировано» на конец 2025. В пульте почти всё это уже есть: реакции и стикеры в видео, размытие и фоны, рисование пальцем, субтитры с переводом. Яндекс, что с лицом?

запросы людей к разрабам Телемоста
запросы людей к разрабам Телемоста

Статья написана на основе версии 5.1.0 расширения. На данный момент ожидаю деплоя 6.0.0 версии с новым названием, интерфейсом и поддержкой 30 веб площадок помимо Яндекс.Телемоста.Что почитать

Идея:

Камера и видеопоток:

Распознавание лица, рук и силуэта:

Речь, перевод и звук:

Расширения Chrome:

Комментарии (0)