Подмена камеры в Яндекс.Телемосте

В 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 — поправка на автоэкспозицию и баланс белого. Стоит сесть в кадр в тёмной футболке, камера подтягивает яркость всей картинки, и без поправки вся комната начинает отличаться от эталона. Сдвиг считается в два прохода: сначала средняя разница по всему кадру, потом она же, но только по пикселям, которые и так близки к эталону, то есть по фону. Её и вычитаю.
Сырая маска после порога рваная, поэтому дальше её чищу:
Дважды размываю и снова отсекаю по порогу — уходит шум, края становятся ровнее.
Оставляю только самое большое пятно, остальное считаю мусором.
Заливаю фон от краёв кадра. Куда заливка не дошла — это человек, так закрываются дыры на футболке под цвет стены.
Эффект Таноса устроен так: каждому пикселю заранее выдаётся порог, который зависит от координаты по 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 веб площадок помимо Яндекс.Телемоста.Что почитать
Идея:
Snap для Google Meet — расширение, с которого всё началось.
Камера и видеопоток:
Распознавание лица, рук и силуэта:
Речь, перевод и звук:
Audio Input Effects Криса Уилсона — оттуда питч‑шифтер для изменения голоса.
Расширения Chrome:
Требования Chrome Web Store к Manifest V3 — почему нельзя подгружать код со сторонних адресов.
chrome.storage — где хранится снимок комнаты.
IndexedDB на MDN — где хранятся свои стикеры.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.