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 и фон молча не включался.
This commit is contained in:
74
frontend/src/lib/imageResize.ts
Normal file
74
frontend/src/lib/imageResize.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Сжатие картинки фона перед отправкой на сервер.
|
||||
*
|
||||
* «При добавлении картинок ужимать их до приемлемого размера, чтобы не грузили
|
||||
* БД» — требование задачи. Жмём именно на КЛИЕНТЕ: фон всё равно рендерится в
|
||||
* браузере, а на 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,
|
||||
)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user