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:
2026-08-10 08:59:47 +03:00
parent fec9255baa
commit 17437880b1
37 changed files with 1918 additions and 18 deletions

View File

@@ -0,0 +1,33 @@
# Дефолтные фоны замены фона видео
Три сцены, которые предлагаются участнику «из коробки» при включённом модуле
«Замена фона» (`instance_settings.virtual_background`):
| файл | сцена |
|---|---|
| `office.svg` | офис: окно с городом, картина, лампа, стеллаж, растение |
| `beach.svg` | пляж: море, песок, пальмы по краям кадра |
| `space-station.svg` | космическая станция: панели, иллюминатор с Землёй |
## Откуда взялись картинки
**Это собственные векторные рисунки VidConf, а не фотографии из интернета.**
Так решено сознательно (решение оператора от 09.08.2026): чужая фотография
тянет за собой чужую лицензию, а продукт самоуправляемый и расходится по
инсталляциям — проверять права на каждую копию некому. У нарисованной сцены
правовых вопросов нет вовсе.
Сцены намеренно расфокусированы (`feGaussianBlur`) и оставляют спокойным центр
кадра: там находится человек, а резкая графика за спиной сильнее всего выдаёт
подмену фона.
## Как пересобрать
```bash
python3 design/backgrounds/render.py
```
Скрипт растеризует каждый `*.svg` в 1280×720 через headless Chrome и кладёт
результат в `frontend/public/backgrounds/*.webp` (именно эти файлы и
раздаются фронтендом). Нужны установленный Google Chrome и Pillow.
Правьте SVG — WebP пересобирается из них, вручную растровые файлы не трогаем.

View File

@@ -0,0 +1,106 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<title>Пляж с пальмами — дефолтный фон замены фона видео</title>
<desc>Собственная векторная сцена VidConf. Море, песок и пальмы по краям кадра;
центр оставлен спокойным — там находится человек.</desc>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#7fc4e8"/>
<stop offset="0.55" stop-color="#bfe4f2"/>
<stop offset="1" stop-color="#f2e7c9"/>
</linearGradient>
<linearGradient id="sea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2a8fb5"/>
<stop offset="0.5" stop-color="#3aa8c4"/>
<stop offset="1" stop-color="#63c8cf"/>
</linearGradient>
<linearGradient id="sand" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f0dfb8"/>
<stop offset="1" stop-color="#d8bf90"/>
</linearGradient>
<linearGradient id="trunk" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#8c6239"/>
<stop offset="1" stop-color="#5f4126"/>
</linearGradient>
<radialGradient id="sun" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#fffbe8" stop-opacity="0.95"/>
<stop offset="1" stop-color="#fff3c4" stop-opacity="0"/>
</radialGradient>
<radialGradient id="beachVignette" cx="0.5" cy="0.45" r="0.8">
<stop offset="0.55" stop-color="#000000" stop-opacity="0"/>
<stop offset="1" stop-color="#1f4a5c" stop-opacity="0.26"/>
</radialGradient>
<filter id="seaSoft" x="-5%" y="-5%" width="110%" height="110%">
<feGaussianBlur stdDeviation="4"/>
</filter>
<filter id="palmSoft" x="-8%" y="-8%" width="116%" height="116%">
<feGaussianBlur stdDeviation="7"/>
</filter>
</defs>
<rect width="1280" height="720" fill="url(#sky)"/>
<ellipse cx="900" cy="150" rx="300" ry="230" fill="url(#sun)"/>
<g filter="url(#seaSoft)">
<!-- Облака -->
<ellipse cx="270" cy="128" rx="130" ry="34" fill="#ffffff" opacity="0.72"/>
<ellipse cx="350" cy="112" rx="88" ry="28" fill="#ffffff" opacity="0.6"/>
<ellipse cx="1010" cy="96" rx="112" ry="28" fill="#ffffff" opacity="0.5"/>
<!-- Море -->
<rect x="0" y="352" width="1280" height="176" fill="url(#sea)"/>
<!-- Блики на воде -->
<g fill="#ffffff" opacity="0.4">
<rect x="120" y="392" width="150" height="6" rx="3"/>
<rect x="360" y="420" width="210" height="6" rx="3"/>
<rect x="700" y="400" width="170" height="6" rx="3"/>
<rect x="980" y="440" width="190" height="6" rx="3"/>
<rect x="240" y="466" width="130" height="5" rx="2.5"/>
<rect x="820" y="474" width="150" height="5" rx="2.5"/>
</g>
<!-- Пена у берега -->
<path d="M0 512c180 26 360-12 540 8s360 34 740-8v36H0z" fill="#ffffff" opacity="0.75"/>
</g>
<!-- Песок -->
<rect x="0" y="530" width="1280" height="190" fill="url(#sand)"/>
<g fill="#c9ac7c" opacity="0.45">
<ellipse cx="300" cy="640" rx="180" ry="18"/>
<ellipse cx="960" cy="682" rx="220" ry="20"/>
</g>
<!-- Пальмы по краям кадра -->
<g filter="url(#palmSoft)">
<g>
<path d="M168 704c-6-190 6-320 40-420l26 8c-30 100-40 226-34 412z" fill="url(#trunk)"/>
<g fill="#2f7a4f">
<path d="M234 292c-84-30-150-16-196 40 66-16 128-14 186 12z"/>
<path d="M234 292c-58-72-124-96-196-76 62 22 116 58 164 102z"/>
<path d="M240 288c50-72 116-98 190-82-62 26-114 62-158 108z"/>
<path d="M242 296c86-24 154-4 198 56-66-22-128-24-186 4z"/>
<path d="M238 282c14-78-8-140-70-176 26 66 38 128 40 184z"/>
</g>
<g fill="#245f3e">
<path d="M236 300c-46 16-88 46-124 90 48-32 94-52 138-60z"/>
<path d="M240 300c50 14 94 46 130 92-48-34-96-56-142-64z"/>
</g>
<circle cx="228" cy="306" r="12" fill="#7a5a30"/>
<circle cx="252" cy="312" r="11" fill="#7a5a30"/>
</g>
<g>
<path d="M1136 720c10-166 2-278-26-360l-24 8c26 84 34 190 26 352z" fill="url(#trunk)"/>
<g fill="#2f7a4f">
<path d="M1084 352c72-30 134-20 178 30-60-16-118-14-172 8z"/>
<path d="M1084 352c50-66 110-90 176-72-56 20-106 54-150 96z"/>
<path d="M1080 348c-46-64-104-88-170-74 56 24 102 58 142 100z"/>
<path d="M1078 356c-78-22-140-4-180 50 60-20 116-22 168 4z"/>
</g>
<g fill="#245f3e">
<path d="M1082 360c42 14 80 42 112 82-42-30-84-48-124-56z"/>
</g>
<circle cx="1090" cy="364" r="11" fill="#7a5a30"/>
</g>
</g>
<rect width="1280" height="720" fill="url(#beachVignette)"/>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1,130 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<title>Офис — дефолтный фон замены фона видео</title>
<desc>Собственная векторная сцена VidConf. Светлый переговорный угол: окно с
тёплым светом, стеллаж с книгами и растениями. Центр кадра намеренно спокойный —
там будет человек, детали вынесены к краям.</desc>
<defs>
<linearGradient id="wall" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f3ede4"/>
<stop offset="0.62" stop-color="#e7ded1"/>
<stop offset="1" stop-color="#d9cebe"/>
</linearGradient>
<linearGradient id="floor" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#c9b49a"/>
<stop offset="1" stop-color="#a98e70"/>
</linearGradient>
<linearGradient id="daylight" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fdfaf2"/>
<stop offset="1" stop-color="#e8eef1"/>
</linearGradient>
<linearGradient id="wood" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#b58a5f"/>
<stop offset="1" stop-color="#8d6540"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#fff6de" stop-opacity="0.85"/>
<stop offset="1" stop-color="#fff6de" stop-opacity="0"/>
</radialGradient>
<radialGradient id="vignette" cx="0.5" cy="0.45" r="0.78">
<stop offset="0.55" stop-color="#000000" stop-opacity="0"/>
<stop offset="1" stop-color="#3b2d1e" stop-opacity="0.3"/>
</radialGradient>
<!-- Мягкое расфокусирование: фон за спиной человека в кадре камеры никогда
не бывает резким, и резкая графика выдаёт подмену сильнее всего. -->
<filter id="soft" x="-6%" y="-6%" width="112%" height="112%">
<feGaussianBlur stdDeviation="5"/>
</filter>
<filter id="softer" x="-8%" y="-8%" width="116%" height="116%">
<feGaussianBlur stdDeviation="10"/>
</filter>
</defs>
<rect width="1280" height="720" fill="url(#wall)"/>
<g filter="url(#soft)">
<!-- Окно слева -->
<rect x="52" y="86" width="330" height="392" rx="10" fill="#cbb9a3"/>
<rect x="66" y="100" width="302" height="364" rx="6" fill="url(#daylight)"/>
<rect x="212" y="100" width="10" height="364" fill="#cbb9a3"/>
<rect x="66" y="276" width="302" height="10" fill="#cbb9a3"/>
<!-- Городская дымка за стеклом -->
<rect x="66" y="352" width="302" height="112" fill="#dfe6e6" opacity="0.85"/>
<rect x="96" y="300" width="46" height="164" fill="#d3dcde" opacity="0.7"/>
<rect x="158" y="330" width="34" height="134" fill="#cfd8db" opacity="0.6"/>
<rect x="248" y="316" width="52" height="148" fill="#d3dcde" opacity="0.65"/>
<rect x="314" y="344" width="30" height="120" fill="#cfd8db" opacity="0.55"/>
<!-- Стеллаж справа -->
<rect x="876" y="120" width="352" height="392" rx="8" fill="url(#wood)"/>
<rect x="892" y="136" width="320" height="112" fill="#e9dcc9" opacity="0.55"/>
<rect x="892" y="264" width="320" height="112" fill="#e9dcc9" opacity="0.5"/>
<rect x="892" y="392" width="320" height="104" fill="#e9dcc9" opacity="0.45"/>
<!-- Книги -->
<g>
<rect x="906" y="156" width="18" height="92" fill="#8a5a4a"/>
<rect x="928" y="168" width="14" height="80" fill="#6c7f6a"/>
<rect x="946" y="150" width="20" height="98" fill="#c08a4a"/>
<rect x="970" y="172" width="16" height="76" fill="#4f6478"/>
<rect x="990" y="160" width="12" height="88" fill="#9d5f5f"/>
<rect x="1010" y="176" width="22" height="72" fill="#7a6a55"/>
<rect x="906" y="292" width="16" height="84" fill="#5f7382"/>
<rect x="926" y="284" width="20" height="92" fill="#a86f4e"/>
<rect x="950" y="300" width="14" height="76" fill="#7f8f76"/>
<rect x="968" y="288" width="18" height="88" fill="#8d5a63"/>
</g>
<!-- Растение на полке -->
<g>
<path d="M1140 392c-26-14-40-42-34-72 26 6 44 30 44 60z" fill="#4f7a52"/>
<path d="M1150 392c26-16 38-46 30-76-26 8-42 34-40 64z" fill="#5f8f5f"/>
<rect x="1128" y="386" width="40" height="30" rx="5" fill="#b98a63"/>
</g>
<!-- Картина на стене между окном и стеллажом -->
<g>
<rect x="486" y="150" width="196" height="146" rx="6" fill="#c2a882"/>
<rect x="498" y="162" width="172" height="122" fill="#eae3d3"/>
<path d="M498 284l52-58 40 34 44-52 36 76z" fill="#9fb5a2"/>
<circle cx="622" cy="196" r="16" fill="#e8c579"/>
</g>
<!-- Настольная лампа и край стола справа от картины -->
<g>
<rect x="700" y="452" width="220" height="14" rx="4" fill="#c8a878"/>
<rect x="712" y="466" width="12" height="46" fill="#b2915f"/>
<rect x="896" y="466" width="12" height="46" fill="#b2915f"/>
<rect x="792" y="392" width="8" height="60" fill="#6f7b82"/>
<path d="M760 392l36-52 36 52z" fill="#7f8d95"/>
<ellipse cx="796" cy="424" rx="54" ry="18" fill="#ffe9b8" opacity="0.5"/>
</g>
</g>
<!-- Пол -->
<rect x="0" y="512" width="1280" height="208" fill="url(#floor)"/>
<rect x="0" y="506" width="1280" height="14" fill="#e9e0d3" opacity="0.7"/>
<!-- Крупное растение в левом углу — передний план, размыто сильнее -->
<g filter="url(#softer)" opacity="0.95">
<g fill="#3f6b45">
<ellipse cx="88" cy="508" rx="52" ry="34" transform="rotate(-24 88 508)"/>
<ellipse cx="206" cy="556" rx="56" ry="36" transform="rotate(18 206 556)"/>
<ellipse cx="70" cy="596" rx="48" ry="32" transform="rotate(-8 70 596)"/>
</g>
<g fill="#4c7f52">
<ellipse cx="158" cy="486" rx="50" ry="32" transform="rotate(6 158 486)"/>
<ellipse cx="248" cy="626" rx="46" ry="30" transform="rotate(32 248 626)"/>
<ellipse cx="124" cy="574" rx="54" ry="34" transform="rotate(-14 124 574)"/>
</g>
<g stroke="#35603c" stroke-width="7" fill="none" stroke-linecap="round">
<path d="M120 668c-16-56-24-108-32-160"/>
<path d="M132 668c14-52 34-96 62-136"/>
<path d="M126 668c2-44 20-76 46-96"/>
</g>
<rect x="52" y="656" width="150" height="86" rx="14" fill="#9d7550"/>
<rect x="52" y="656" width="150" height="16" rx="8" fill="#b08a63"/>
</g>
<!-- Свет из окна и общая виньетка -->
<ellipse cx="300" cy="250" rx="460" ry="330" fill="url(#glow)"/>
<rect width="1280" height="720" fill="url(#vignette)"/>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Запекает векторные сцены дефолтных фонов (`*.svg`) в WebP для фронтенда.
Исходники сцен — собственные рисунки VidConf (см. README рядом), поэтому
лежат в репозитории вместе с результатом: чужих фотографий с их лицензиями в
продукте нет.
Запуск (нужен установленный Google Chrome и Pillow):
python3 design/backgrounds/render.py
Результат — `frontend/public/backgrounds/*.webp`, 1280×720. Chrome нужен
только для растеризации SVG (rsvg/ImageMagick на машине может не быть),
Pillow — для перевода PNG в WebP: браузерный скриншот WebP не отдаёт.
"""
import subprocess
import sys
import tempfile
from pathlib import Path
from PIL import Image
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
WIDTH, HEIGHT = 1280, 720
# 82 — визуально неотличимо от исходника на плавных градиентах сцены, а вес
# втрое меньше, чем при 95. Фон грузится по сети при каждом первом показе.
WEBP_QUALITY = 82
SOURCE_DIR = Path(__file__).parent
TARGET_DIR = SOURCE_DIR.parents[1] / "frontend" / "public" / "backgrounds"
def render(svg_path: Path, out_path: Path, workdir: Path) -> None:
"""Растеризовать одну сцену и сохранить её в WebP."""
png_path = workdir / f"{svg_path.stem}.png"
subprocess.run(
[
CHROME,
"--headless",
"--disable-gpu",
"--hide-scrollbars",
"--default-background-color=00000000",
f"--screenshot={png_path}",
f"--window-size={WIDTH},{HEIGHT}",
svg_path.resolve().as_uri(),
],
check=True,
capture_output=True,
)
with Image.open(png_path) as image:
image.convert("RGB").save(out_path, "WEBP", quality=WEBP_QUALITY, method=6)
def main() -> int:
if not Path(CHROME).exists():
print(f"не найден Chrome: {CHROME}", file=sys.stderr)
return 1
TARGET_DIR.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
workdir = Path(tmp)
for svg_path in sorted(SOURCE_DIR.glob("*.svg")):
out_path = TARGET_DIR / f"{svg_path.stem}.webp"
render(svg_path, out_path, workdir)
print(
f"{svg_path.name}{out_path.relative_to(SOURCE_DIR.parents[1])}"
f" ({out_path.stat().st_size // 1024} КБ)"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,150 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<title>Космическая станция — дефолтный фон замены фона видео</title>
<desc>Собственная векторная сцена VidConf. Интерьер модуля станции: панели,
подсветка, иллюминатор с Землёй. Центр кадра спокойный — там человек.</desc>
<defs>
<linearGradient id="hull" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2b3446"/>
<stop offset="0.5" stop-color="#222a3a"/>
<stop offset="1" stop-color="#161c28"/>
</linearGradient>
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#39445a"/>
<stop offset="1" stop-color="#28304180"/>
</linearGradient>
<linearGradient id="strip" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#4fd7ff" stop-opacity="0.1"/>
<stop offset="0.5" stop-color="#7fe8ff" stop-opacity="0.9"/>
<stop offset="1" stop-color="#4fd7ff" stop-opacity="0.1"/>
</linearGradient>
<radialGradient id="earth" cx="0.36" cy="0.32" r="0.78">
<stop offset="0" stop-color="#7fd1f5"/>
<stop offset="0.55" stop-color="#2f7fc4"/>
<stop offset="1" stop-color="#0c2c56"/>
</radialGradient>
<radialGradient id="atmo" cx="0.5" cy="0.5" r="0.5">
<stop offset="0.78" stop-color="#8fd8ff" stop-opacity="0"/>
<stop offset="0.9" stop-color="#8fd8ff" stop-opacity="0.5"/>
<stop offset="1" stop-color="#8fd8ff" stop-opacity="0"/>
</radialGradient>
<radialGradient id="portGlow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#8fd8ff" stop-opacity="0.4"/>
<stop offset="1" stop-color="#8fd8ff" stop-opacity="0"/>
</radialGradient>
<radialGradient id="spaceVignette" cx="0.5" cy="0.45" r="0.78">
<stop offset="0.5" stop-color="#000000" stop-opacity="0"/>
<stop offset="1" stop-color="#05080f" stop-opacity="0.65"/>
</radialGradient>
<filter id="stationSoft" x="-6%" y="-6%" width="112%" height="112%">
<feGaussianBlur stdDeviation="4"/>
</filter>
<filter id="stationSofter" x="-8%" y="-8%" width="116%" height="116%">
<feGaussianBlur stdDeviation="9"/>
</filter>
<!-- Всё, что «за стеклом», обрезается по стеклу: без этого Земля вылезала
за переплёт иллюминатора и сцена читалась как наклейка на стене. -->
<clipPath id="portholeGlass">
<circle cx="640" cy="318" r="192"/>
</clipPath>
</defs>
<rect width="1280" height="720" fill="url(#hull)"/>
<g filter="url(#stationSoft)">
<!-- Рёбра модуля -->
<g fill="#39445a" opacity="0.55">
<rect x="0" y="0" width="1280" height="52"/>
<rect x="0" y="668" width="1280" height="52"/>
<rect x="150" y="52" width="26" height="616"/>
<rect x="1104" y="52" width="26" height="616"/>
</g>
<!-- Приборные панели слева -->
<g>
<rect x="46" y="150" width="220" height="300" rx="14" fill="url(#panel)"/>
<rect x="66" y="172" width="180" height="86" rx="6" fill="#101826"/>
<g fill="#5ee0a8" opacity="0.85">
<rect x="78" y="230" width="120" height="4" rx="2"/>
<rect x="78" y="216" width="82" height="4" rx="2"/>
<rect x="78" y="202" width="146" height="4" rx="2"/>
</g>
<g>
<circle cx="92" cy="292" r="11" fill="#ff8a5c"/>
<circle cx="126" cy="292" r="11" fill="#ffd166"/>
<circle cx="160" cy="292" r="11" fill="#5ee0a8"/>
</g>
<g fill="#4a5670">
<rect x="66" y="322" width="180" height="16" rx="8"/>
<rect x="66" y="352" width="180" height="16" rx="8"/>
<rect x="66" y="382" width="180" height="16" rx="8"/>
</g>
<rect x="66" y="322" width="104" height="16" rx="8" fill="#7fe8ff" opacity="0.8"/>
<rect x="66" y="352" width="62" height="16" rx="8" fill="#7fe8ff" opacity="0.6"/>
</g>
<!-- Стеллаж оборудования справа -->
<g>
<rect x="1014" y="128" width="230" height="344" rx="14" fill="url(#panel)"/>
<g fill="#101826">
<rect x="1034" y="150" width="190" height="70" rx="6"/>
<rect x="1034" y="238" width="190" height="70" rx="6"/>
<rect x="1034" y="326" width="190" height="70" rx="6"/>
</g>
<g fill="#7fe8ff" opacity="0.6">
<rect x="1050" y="176" width="86" height="5" rx="2.5"/>
<rect x="1050" y="264" width="126" height="5" rx="2.5"/>
<rect x="1050" y="352" width="64" height="5" rx="2.5"/>
</g>
<circle cx="1206" cy="424" r="14" fill="#ff8a5c" opacity="0.8"/>
</g>
<!-- Иллюминатор -->
<g>
<circle cx="640" cy="318" r="212" fill="#39445a"/>
<circle cx="640" cy="318" r="192" fill="#0b1120"/>
<g clip-path="url(#portholeGlass)">
<!-- Звёзды -->
<g fill="#ffffff">
<circle cx="530" cy="200" r="2.5" opacity="0.9"/>
<circle cx="596" cy="168" r="1.8" opacity="0.7"/>
<circle cx="712" cy="188" r="2.2" opacity="0.8"/>
<circle cx="768" cy="252" r="1.6" opacity="0.6"/>
<circle cx="500" cy="300" r="1.8" opacity="0.65"/>
<circle cx="742" cy="356" r="2" opacity="0.7"/>
<circle cx="556" cy="424" r="1.7" opacity="0.6"/>
<circle cx="676" cy="456" r="2.3" opacity="0.75"/>
</g>
<!-- Земля -->
<circle cx="596" cy="386" r="150" fill="url(#earth)"/>
<g fill="#3f8f5f" opacity="0.75">
<path d="M520 330c40-16 74-10 96 14-34 8-64 12-96 6z"/>
<path d="M596 430c46-8 84 6 108 40-42 4-80-8-108-30z"/>
<path d="M498 404c26 6 46 22 58 46-28-4-50-18-66-38z"/>
</g>
<g fill="#ffffff" opacity="0.35">
<ellipse cx="560" cy="352" rx="70" ry="16"/>
<ellipse cx="656" cy="416" rx="86" ry="18"/>
</g>
<circle cx="596" cy="386" r="150" fill="url(#atmo)"/>
</g>
<!-- Переплёт иллюминатора -->
<circle cx="640" cy="318" r="192" fill="none" stroke="#4a5670" stroke-width="14"/>
<circle cx="640" cy="318" r="206" fill="none" stroke="#2b3446" stroke-width="16"/>
<g fill="#4a5670">
<rect x="622" y="98" width="36" height="34" rx="8"/>
<rect x="622" y="504" width="36" height="34" rx="8"/>
<rect x="420" y="300" width="34" height="36" rx="8"/>
<rect x="826" y="300" width="34" height="36" rx="8"/>
</g>
</g>
</g>
<!-- Свечение из иллюминатора и световые полосы модуля -->
<ellipse cx="640" cy="318" rx="420" ry="330" fill="url(#portGlow)"/>
<g filter="url(#stationSofter)">
<rect x="176" y="60" width="928" height="10" rx="5" fill="url(#strip)"/>
<rect x="176" y="650" width="928" height="10" rx="5" fill="url(#strip)"/>
</g>
<rect width="1280" height="720" fill="url(#spaceVignette)"/>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB