javascript

Наводишь на слово в субтитрах — видишь перевод: расширение на встроенном ИИ Chrome

  • понедельник, 28 сентября 2026 г. в 00:00:09
https://habr.com/ru/articles/1087092/

Я учу английский по сериалам. Смотрю с английскими субтитрами, и каждое незнакомое слово — это пауза, alt‑tab, переводчик, обратно. К десятой паузе за серию хочется просто включить русские сабы и сдаться.

Есть Language Reactor и похожие сервисы, но у них жёсткий список площадок: Netflix, YouTube — и всё. Каждый стриминг рисует субтитры своим способом, и под каждый нужен отдельный парсер. А я смотрю где придётся.

Так что я сделал своё. Получилось расширение, которое работает почти на любом сайте с HTML5-плеером: наводишь на слово в субтитрах — тултип с переводом, кликаешь — слово уходит в личный словарь и подчёркивается во всех следующих сериях, пока не выучишь. Перевод крутится прямо в браузере, без серверов.

Chrome теперь переводит сам, без API‑ключей

Главное открытие проекта: в Chrome 138+ есть встроенный Translator API — модель работает on‑device, бесплатно и офлайн. Проверка доступности выглядит так:

const availability = await Translator.availability({
  sourceLanguage: 'en', targetLanguage: 'ru'
});
// 'available' | 'downloadable' | 'unavailable'
const translator = await Translator.create({ sourceLanguage: 'en', targetLanguage: 'ru' });
await translator.translate('stubborn'); // «упрямый»

Нюансы, которые не написаны в документации крупным шрифтом:

  • Первое скачивание языкового пакета Chrome разрешает только по пользовательскому жесту — по наведению мыши модель не скачать, нужен клик.

  • Translator.availability() умеет просто не резолвиться никогда, если сервис моделей не поднялся. При этом 'Translator' in window === true. Без таймаутов вокруг каждого вызова интерфейс залипает на «…» навсегда.

Грабли № 1: субтитры — это не предложения

Первая версия переводила текст реплики как есть, и перевод был мусорным. Причина не в модели: субтитры режут предложение на куски, и переводчику достаётся огрызок.

Что уходит в переводчик

Что получается

...turns out I'm

«... оказывается, я»

It turns out I'm not who I thought I was.

«Оказывается, я не тот, кем я думал.»

Лечится склейкой: у дорожек <track> доступен весь список реплик с таймингами, так что предложение собирается по соседним репликам до ближайшей точки — и назад, и вперёд.

Грабли № 2: у YouTube субтитров нет. И есть

YouTube не использует <track> — субтитры это div'ы, которые он рисует сам. Причём контейнер держит два окна сразу (уходящее и текущее), а роллинг‑субтитры повторяют в каждой следующей реплике хвост предыдущей. Наивное чтение контейнера даёт задвоенный текст, а наивная склейка — «я провёл месяцы я провёл месяцы я провёл месяцы» в переводе.

Решение: читать только последнее окно, повторы склеивать по перекрытию слов, как это делают в диффах.

Грабли № 3: requestAnimationFrame в фоновой вкладке

Сканер видео на странице я дебаунсил через requestAnimationFrame. В фоновой вкладке rAF не вызывается вообще — флаг «скан запланирован» залипал в true, и сканер умирал навсегда, даже после возврата на вкладку. setTimeout и никаких украшательств.

Грабли № 4: fullscreen на самом

В нативном fullscreen браузер рисует только document.fullscreenElement и его потомков. Если плеер уходит в fullscreen контейнером — оверлей просто перевешивается внутрь. Но часть сайтов открывает fullscreen на самом <video>, а у видеоэлемента не бывает детей — оверлей туда физически не подвесить. Выход: поймать момент, выйти из fullscreen и тут же запросить его для родителя, пока пользовательский жест ещё считается активным.

Грабли № 5: расширение само сломало интерфейс твича

Самая поучительная история проекта, и мне про неё сообщили как про «на твиче лагает и пропадает меню громкости».

Для плееров без <track> и без известного селектора работает эвристика: полминуты смотрим на элементы внутри кадра и берём тот, чей текст сменился хотя бы трижды. Логика простая — субтитры меняются, остальное стоит.

На твиче меняется всё. Качество, громкость, счётчик зрителей, время эфира. Эвристика находила счётчик зрителей, объявляла его субтитрами и ставила ему visibility: hidden — а потом перерисовывала «реплику» на каждое обновление счётчика. Отсюда и «пропадает меню», и «лагает»: причина была одна, и причиной был я, а не твич.

Признаков в итоге четыре, и элемент обязан пройти все:

  • не орган управления: сам не кнопка, кнопок внутри не содержит и внутри кнопки не лежит;

  • текст похож на предложение: минимум три слова из букв, либо два при длине от 14 символов. 12,4 тыс., 1080p60, Auto, 100%, 1:02:13 не проходят. Для письма без пробелов — японского, китайского, тайского — считаются не слова, а буквы: от пяти;

  • стоит по центру кадра: середина элемента не дальше 20% ширины кадра от его центра. Качество, громкость и счётчик живут по углам;

  • целиком внутри кадра и не является предком видео.

Первой версии этих защит не хватило, и узнал я это не глазами: собрал стенд, устроенный как твич — четыре тысячи узлов внутри плеера, живая лента чата, подписи, которые меняются каждые 400 мс. Стенд сразу показал, что счётчик зрителей всё ещё забирается: он не кнопка и внутри кнопки не лежит, поэтому первую проверку проходил. Признаки «предложение» и «по центру» появились из‑за него.

Мораль скучная, но настоящая: если эвристика решает, что на чужой странице считать своим, у неё должен быть стенд, а не «вроде работает».

Грабли № 6: prefers‑reduced‑motion, который добавлял движения

Универсальный зажим в CSS выглядел безобидно:

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.01ms !important; }
}

Начальное значение animation-delay он не трогает, а анимация с задержкой и fill: both держит стартовый кадр всю задержку целиком. То есть под «уменьшить движение» страница начинала двигаться больше обычного: элементы возникали по очереди из ниоткуда, без всякого движения между появлениями. Лечится одной строкой animation-delay: 0ms !important рядом, но найти это можно только специально включив флаг и посмотрев.

Идиомы: предел маленькой модели

«This is some dirty fucking pool, Madelyn» встроенный переводчик переводит про бассейн. А «dirty pool» — это «нечестная игра». Маленькая on‑device модель идиом не знает, и промптами Translator API это не лечится — он переводчик, а не LLM.

Зато рядом лежит Prompt API — тот же on‑device Gemini Nano, но в режиме языковой модели. Идиомы он понимает. Ловушка: Chrome даёт этот API только расширениям, из userscript'а он недоступен — из‑за этого проект и переехал из Tampermonkey в MV3.

Дальше пришлось решать, где им переводить, а где нет. Модель отвечает секундами, а не мгновенно, и перевод реплики в тултипе срабатывает на каждое наведение мыши — туда её пускать нельзя. А вот сохранение фразы (Alt+A или протяжка мышью) — осознанное действие, там секунда ожидания уместна, и результат кэшируется навсегда. Так и сделано: два пути перевода фраз, быстрый и глубокий, с общим кэшем и разными ячейками в нём — иначе первое же наведение на слово закрыло бы дорогу хорошему переводу дословным.

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

DeepL остался как опция для тех, у кого модель не скачана: свой бесплатный ключ, 500 тыс. символов в месяц. И тут был отдельный урок — см. ниже.

Грабли № 7: опция, которая ломает продукт

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

const key = await getDeeplKey();
if (key) return deeplTranslate(text, src, tgt, key);   // и всё

Сохранённый нерабочий ключ оказался хуже, чем никакого. Причём падений тут четыре разных класса: 451 (страна), 456 (кончился месячный лимит), 401/403 (ключ отозван), обрыв сети. Ни один из них не должен уносить с собой основную функцию.

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

Общее правило, которое я вынес: опциональная интеграция обязана иметь запасной путь на каждом своём отказе. Иначе это не опция, а скрытая зависимость.

Что получилось

  • Наведение — перевод слова и всей реплики; клик — слово в словарь вместе с фразой‑примером; протяжка по словам — целая фраза.

  • Сохранённые слова подчёркиваются в субтитрах во всех следующих видео.

  • Alt+E — объяснение реплики on‑device моделью: идиомы, сленг, фразовые глаголы.

  • Тренажёр с интервальным повторением, статистика, выгрузка в CSV, Excel, Anki и PDF.

  • Учить можно в любую сторону: не только английский по русскому переводу, но и русский по английскому. 39 языков — всё, что Chrome переводит on‑device.

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

  • Выключатель на конкретном сайте: скрипт там засыпает целиком — ни поиска видео, ни наблюдателей, ни таймеров.

  • Всё on‑device: без аккаунтов, без телеметрии, субтитры никуда не уходят (кроме опционального DeepL по твоему собственному ключу).

Чего нет и что сломано

  • Селекторы под Netflix, Twitch, video.js, JW Player, Plyr и Shaka написаны по их разметке и покрыты синтетическими стендами. Живьём я их не проверял — нет подписки и нет эфира. Если у вас работает или не работает — напишите, это ровно та обратная связь, которой мне не хватает.

  • Требуется Chrome 138+ на компьютере. На мобильных встроенных моделей нет.

  • Языковую пару без английского Chrome тянет через английский: два языковых пакета и потеря качества.

Расширение бесплатное: https://chromewebstore.google.com/detail/subtitle‑translator‑learn/floomboamhlmkalghefpabefhciknllf

Если найдёте сайт, где субтитры есть, а расширение их не видит — напишите, адаптеры под новые плееры добавляются быстро.