Files
vidconf/frontend/src/lib/imageResize.ts
Max Ronzhin 17437880b1 feat(room): замена фона видео на картинку — только на десктопе
Кнопка «Фон» в тулбаре комнаты и выбор фона в превью на входе: три готовые
сцены и свои картинки из профиля. Фон применяется процессором к самому
публикуемому треку (`LocalVideoTrack.setProcessor`), а НЕ пересозданием
`RoomOptions` — ссылка на них обязана оставаться стабильной, иначе
`LiveKitRoom` переподключается к комнате.

Фича только для ДЕСКТОПА, и «десктоп» определяется по возможностям устройства
(`pointer: fine` + `hover: hover` + `maxTouchPoints`), а НЕ по ширине окна:
узкое окно на десктопе — всё ещё десктоп, а широкий планшет — всё ещё планшет,
который сегментация греет. На мобильном кнопки нет вовсе, а не задизейбленной.

Ассеты сегментации отдаются СО СВОЕГО домена: библиотека по умолчанию тянет
wasm с jsdelivr, а модель с storage.googleapis.com, и в закрытом контуре фича
молча не работала бы. Модель (Apache 2.0, см. NOTICE.txt) лежит в репозитории,
wasm-рантайм (~19 МБ) копируется из node_modules плагином сборки. Сама
библиотека и модель грузятся ЛЕНИВО — только когда фон реально включают, вход
в конференцию не стал медленнее.

Три дефолтные сцены — собственные векторные рисунки (`design/backgrounds/`),
а не фотографии из интернета: у нарисованной сцены нет чужой лицензии, а
продукт расходится по инсталляциям, и проверять права на каждую копию некому.

Свои картинки — в профиле, до 10 штук, с уменьшением до 1280px и переводом в
WebP прямо в браузере перед отправкой. Удаление применённого сейчас фона
сбрасывает выбор на «без фона»: хранится ключ записи, а не URL картинки.

Смена камеры фон не теряет (`restartTrack` перезапускает процессор сам),
выключение и включение камеры — навешивает его на новый трек заново.

⚠️ Прокси dev-сервера для `/media/` — обязательно со слэшем: ключ `/media`
Vite матчит префиксом и перехватывает заодно `/mediapipe/...`, из-за чего
модель получала 404 и фон молча не включался.
2026-08-10 08:59:47 +03:00

75 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Сжатие картинки фона перед отправкой на сервер.
*
* «При добавлении картинок ужимать их до приемлемого размера, чтобы не грузили
* БД» — требование задачи. Жмём именно на КЛИЕНТЕ: фон всё равно рендерится в
* браузере, а на backend нет Pillow, и ставить его ради одной операции незачем.
* Серверная валидация (тип по магическим байтам, лимит размера) при этом
* остаётся — клиенту верить нельзя, запрос может прийти и мимо интерфейса.
*/
/** Больше 1280 по длинной стороне фону не нужно — столько же у дефолтных сцен (1280×720). */
const MAX_SIDE_PX = 1280
/** Ступени качества WebP: жмём сильнее, только если с прошлой ступени не уложились. */
const QUALITY_STEPS = [0.82, 0.7, 0.6]
/** Цель по весу — 600 КБ. Серверный лимит вдвое больше (2 МБ), запас на подстраховку. */
const TARGET_BYTES = 600 * 1024
export class ImageDecodeError extends Error {}
/**
* Ужать картинку до `MAX_SIDE_PX` по длинной стороне и вернуть WebP-Blob.
*
* Пропорции сохраняются: кадрировать под 16:9 здесь нельзя — какая часть
* картинки важна, знает только пользователь, а сама библиотека замены фона
* вписывает изображение в кадр сама.
*/
export async function resizeImageForBackground(file: File): Promise<Blob> {
const bitmap = await decode(file)
try {
const scale = Math.min(1, MAX_SIDE_PX / Math.max(bitmap.width, bitmap.height))
const width = Math.max(1, Math.round(bitmap.width * scale))
const height = Math.max(1, Math.round(bitmap.height * scale))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')
if (!context) throw new ImageDecodeError('canvas 2d context unavailable')
context.drawImage(bitmap, 0, 0, width, height)
let result: Blob | null = null
for (const quality of QUALITY_STEPS) {
result = await toBlob(canvas, quality)
if (result.size <= TARGET_BYTES) return result
}
// Даже на самом низком качестве не уложились (огромная детализованная
// картинка) — отдаём как есть: серверный лимит вдвое выше цели, и шанс
// пройти его остаётся; иначе пользователь получит честную 413.
return result!
} finally {
bitmap.close()
}
}
/** Декодировать файл в `ImageBitmap`; битый/не-картинка → `ImageDecodeError`. */
async function decode(file: File): Promise<ImageBitmap> {
try {
return await createImageBitmap(file)
} catch (err) {
throw new ImageDecodeError(String(err))
}
}
function toBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new ImageDecodeError('canvas.toBlob returned null'))),
'image/webp',
quality,
)
})
}