Подмена камеры в Яндекс.Телемосте
- среда, 14 октября 2026 г. в 00:00:06
В 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, без видео.
Для лица, рук и сегментации я использую 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 и переживут перезагрузку страницы.
Статус — короткая плашка с текстом под подбородком, привязана к точке подбородка из Face Landmarker и ездит вместе с лицом.
AFK на 1, 3 или 5 минут — плашка «отошёл» с обратным отсчётом.
Бегущая строка внизу кадра. Чтобы не было рывка на стыке, текст рисуется несколько раз подряд со сдвигом ровно на ширину одной копии.
Картинка в углу — схема или QR‑код в кадре без демонстрации экрана, жестом victory показывается и прячется.
Ретро — цветные карточки поверх своего превью. Размер шрифта на карточке подбирается бинарным поиском, чтобы текст любой длины влезал целиком. Для короткого ретро втроём это быстрее, чем звать всех на отдельную доску.

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

Идея:
Snap для Google Meet — расширение, с которого всё началось.
Камера и видеопоток:
Распознавание лица, рук и силуэта:
Речь, перевод и звук:
Audio Input Effects Криса Уилсона — оттуда питч‑шифтер для изменения голоса.
Расширения Chrome:
Требования Chrome Web Store к Manifest V3 — почему нельзя подгружать код со сторонних адресов.
chrome.storage — где хранится снимок комнаты.
IndexedDB на MDN — где хранятся свои стикеры.