Создайте видео с говорящей собакой прямо в браузере: без бэкенда, только клиентский код. Узнайте, как работает пайплайн и как повторить.
Моя собака имеет мнение, но никогда его не высказывала. Поэтому я создал BarkReels: вы загружаете фото, а она получает голос. Рот двигается в такт аудио, она моргает, уши подрагивают, субтитры появляются, и на выходе — настоящий MP4. В этой статье я расскажу, как построить такой пайплайн без единого сервера, и поделюсь кодом, который вы можете использовать для своих проектов.
BarkReels — это клиентское приложение, которое генерирует видео с говорящей собакой. Оно использует API для зрения и синтеза речи, но вся обработка — сегментация, анимация и кодирование — происходит в вашем браузере. Это возможно благодаря современным веб-технологиям: WebGL, WebCodecs и ONNX Runtime. Вам не нужен бэкенд, потому что всё тяжёлое выполняется на вашем GPU.
Пайплайн выглядит так:
фото ──┬─→ vision-модель ──→ порода, настроение, монолог, координаты морды
└─→ ISNet (ONNX, в браузере) ──→ вырезанная собака с альфа-каналом
монолог ──→ ElevenLabs ──→ аудио + таймстампы слов
│
├─→ огибающая громкости (асимметричное сглаживание)
└─→ тайминг субтитров
огибающая + координаты + вырезка ──→ анимационный риг ──→ WebGL-деформация ──→ кадры
│
WebCodecs H.264 + AAC ──┴──→ MP4Ключевая идея — renderFrame(t) — чистая функция от времени. Она не читает часы, не зависит от состояния воспроизведения и не меняет ничего, что повлияло бы на будущие вызовы. Это позволяет использовать один и тот же код для предпросмотра и экспорта, гарантируя идентичный результат.
Синхронизация губ — это не нейросеть. Я использую огибающую громкости аудио с асимметричным сглаживанием: атака 15 мс, спад 90 мс. Мышцы резко открывают пасть и плавно закрывают. Симметричное сглаживание вызывает дрожание рта между слогами, как у индикатора уровня. Эта асимметрия дала больше реализма, чем всё остальное.
import numpy as np
def compute_envelope(audio, sr, attack=0.015, release=0.09):
"""Вычисление огибающей громкости с асимметричным сглаживанием."""
# Прямоугольное интегрирование для получения амплитуды
frame_size = int(sr * 0.01)
hop = frame_size // 2
rms = []
for i in range(0, len(audio) - frame_size, hop):
rms.append(np.sqrt(np.mean(audio[i:i+frame_size]**2)))
rms = np.array(rms)
# Асимметричное сглаживание
attack_coef = 1 - np.exp(-1 / (sr * attack / hop))
release_coef = 1 - np.exp(-1 / (sr * release / hop))
envelope = np.zeros_like(rms)
envelope[0] = rms[0]
for i in range(1, len(rms)):
coef = attack_coef if rms[i] > envelope[i-1] else release_coef
envelope[i] = coef * rms[i] + (1 - coef) * envelope[i-1]
return envelopeЭтот код вычисляет огибающую, которая затем управляет открытием рта в шейдере.
Google Gemini Flash выполняет две задачи за один вызов: пишет монолог от лица собаки и возвращает нормализованные координаты ограничивающих рамок для носа, рта, глаз и ушей. Эти координаты используются шейдером для деформации. Когда вы пишете свой сценарий, запрос изменяется, и модель тратит все внимание на координаты.
import google.generativeai as genai
# Инициализация
genai.configure(api_key='YOUR_API_KEY')
model = genai.GenerativeModel('gemini-1.5-flash')
# Запрос с фото и инструкцией
response = model.generate_content([
{"mime_type": "image/png", "data": photo_bytes},
"Верни JSON: breed, mood, monologue, anchors (nose, mouth, eyes, ears) с координатами в диапазоне [0,1]."
])
# Парсинг ответа
import json
result = json.loads(response.text)
print(result['anchors'])Таким образом, мы получаем координаты, которые анимируем.
ElevenLabs делает больше, чем просто читает текст. Эндпоинт /with-timestamps возвращает тайминги на уровне символов вместе с аудио. Эти тайминги управляют караоке-субтитрами, а огибающая громкости, полученная из того же аудио, управляет челюстью. Голос — не украшение, а источник анимации.
import requests
url = "https://api.elevenlabs.io/v1/text-to-speech/{voice_id}/with-timestamps"
headers = {"xi-api-key": "YOUR_KEY"}
data = {
"text": "Привет, я Барк!",
"model_id": "eleven_monolingual_v1",
"voice_settings": {"stability": 0.5, "similarity_boost": 0.5}
}
response = requests.post(url, json=data, headers=headers)
result = response.json()
# result['audio_base64'] и result['alignment'] содержат таймингиЗатем мы декодируем аудио и вычисляем огибающую, как показано выше.
Анимация в WebGL использует шейдеры, которые деформируют изображение в соответствии с координатами и огибающей. Главный принцип — renderFrame(t) не зависит от внешнего состояния. Это позволяет легко реализовать предпросмотр и экспорт.
// Псевдокод для WebGL-рендера
function renderFrame(t) {
// t — время в секундах
const envelope = getEnvelopeAtTime(t); // из аудио
const mouthOpen = envelope * mouthScale;
// передать uniform в шейдер
gl.uniform1f(mouthLoc, mouthOpen);
// нарисовать текстуру с деформацией
drawScene();
}Экспорт — это просто цикл по кадрам с вызовом renderFrame и кодированием через WebCodecs.
Анимация двигает конкретные анатомические точки, поэтому фото имеет огромное значение. Вот что я выяснил методом проб и ошибок:
Попробуйте сами: зарегистрируйтесь на ElevenLabs и Google AI Studio, получите ключи, откройте BarkReels, вставьте ключи и загрузите фото собаки. За 4 минуты вы получите видео. Оба бесплатных тарифа покрывают расходы, карта не нужна. Ключи хранятся в localStorage и отправляются только провайдеру, потому что моего сервера нет.
Если у вас нет собаки — возьмите фото с Unsplash. Работает с любой собакой.
Изучите код на GitHub: репозиторий. Форкните, включите Pages и разверните. Там есть GitHub Actions для автоматической сборки.
Главный урок: современный браузер способен на многое, если правильно использовать WebGL и WebCodecs. Не бойтесь экспериментировать с клиентской обработкой — это открывает новые возможности для интерактивных приложений.
Если вы сделаете своё видео — поделитесь в комментариях. Мне правда интересно, что ваша собака скрывала.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →