Adapted preview from WebRTC

This commit is contained in:
Cesar Mendivil 2026-03-14 16:16:03 -07:00
parent 00e98a19b3
commit 89446e701f
5 changed files with 973 additions and 54 deletions

View File

@ -108,9 +108,13 @@ Ejemplo visual:
#### 1.6 Configuración WHIP (settings)
Sección colapsable o en la misma pantalla de configuración del Core:
- Toggle **Habilitar WHIP Server** → modifica `config.whip.enable` via `PATCH /api/v3/config`
- Campo **Puerto** → modifica `config.whip.address`
- Toggle **Habilitar WHIP Server** → modifica `config.whip.enable`
- Campo **Puerto WHIP** → modifica `config.whip.address` (ej. `8555`)
- Campo **Token** → modifica `config.whip.token`
- Campo **Puerto relay RTSP interno** → modifica `config.whip.rtsp_address` (ej. `8554`)
- Mostrar tooltip: *"Habilita el relay RTSP para que múltiples procesos FFmpeg puedan consumir el mismo stream WHIP simultáneamente. Dejar vacío para desactivar."*
- Si está vacío: el relay RTSP está desactivado; solo un proceso puede leer el stream con `{whip}`.
- Si está configurado: múltiples procesos pueden usar `{whip-rtsp}` como input.
---
@ -267,7 +271,8 @@ Leer configuración del servidor WHIP para el panel de settings.
"whip": {
"enable": true,
"address": ":8555",
"token": ""
"token": "",
"rtsp_address": ":8554"
}
}
}
@ -392,6 +397,104 @@ El proceso comenzará a recibir video cuando OBS empiece a transmitir.
---
## Parte 5 — Recuperación de estado tras reinicio del Core
Cuando el Core se reinicia, la UI debe re-leer el estado WHIP desde la API. La UI **no debe cachear** los valores localmente — siempre debe obtenerlos del Core en el momento de montar el componente.
### 5.1 Secuencia de inicialización del componente WHIP
Al montar cualquier componente/página que muestre datos WHIP, ejecutar en este orden:
```js
async function load() {
// 1. Leer config del servidor
const { data } = await api.get('/api/v3/config')
const whip = data.config.whip ?? { enable: false, address: ':8555', token: '' }
// 2. Extraer puerto sin el prefijo ':'
const port = whip.address.replace(/^:/, '') || '8555'
// 3. Inicializar estado del formulario
configValues.whip = {
enable: whip.enable,
address: port, // mostrar "8555", no ":8555"
token: whip.token,
rtspAddress: (whip.rtsp_address ?? '').replace(/^:/, ''), // "8554" o ""
}
// 4. Obtener la URL base de publicación (host público)
const urlInfo = await api.get('/api/v3/whip/url')
state.basePublishUrl = urlInfo.data.base_publish_url // "http://189.x.x.x:8555/whip/"
state.hasToken = urlInfo.data.has_token
// 5. Listar publishers activos
const channels = await api.get('/api/v3/whip')
state.activeChannels = channels.data // [{ name, published_at }]
}
```
### 5.2 Detectar que el Core se reinició
El Core no emite eventos de reconexión, pero la UI puede detectarlo por:
- **Polling de `/api/v3/whip`** — si la llamada falla (network error / 502) y luego vuelve a responder, el Core se reinició. Llamar `load()` nuevamente.
- **`updated_at` en `/api/v3/config`** — si cambia respecto al valor previo cacheado, el Core fue reiniciado y la config debe recargarse.
```js
// Ejemplo: detectar reinicio por cambio en updated_at
let lastUpdatedAt = null
setInterval(async () => {
try {
const { data } = await api.get('/api/v3/config')
if (lastUpdatedAt && data.updated_at !== lastUpdatedAt) {
await load() // Core reinició, recargar todo
}
lastUpdatedAt = data.updated_at
} catch {
// Core no disponible temporalmente, reintentar en próximo ciclo
}
}, 10000) // cada 10s
```
### 5.3 Comportamiento esperado en el panel WHIP Server tras reinicio
| Estado | Qué hace la UI |
|---|---|
| Core acaba de arrancar, config.json existe | `load()` lee `whip.token = 'heavy666'` del disco — se muestra correctamente ✅ |
| Core arranca sin config.json (primera vez) | `whip` no existe en respuesta → usar defaults `{ enable: false, address: '8555', token: '' }` |
| Core no responde (arrancando) | Mostrar spinner/skeleton, reintentar cada 2s hasta que responda |
| Core responde pero WHIP disabled | Mostrar badge "Disabled", ocultar la URL de publicación |
### 5.4 Flujo de guardado (`handleSave`)
```js
async function handleSave() {
// Leer config completa actual para no pisar otros campos
const { data } = await api.get('/api/v3/config')
const fullConfig = data.config
// Aplicar cambios WHIP (restaurar ':' en address)
fullConfig.whip = {
enable: configValues.whip.enable,
address: ':' + configValues.whip.address, // "8555" → ":8555"
token: configValues.whip.token,
rtsp_address: configValues.whip.rtspAddress ? ':' + configValues.whip.rtspAddress : '', // "" o ":8554"
}
// Enviar config completa
await api.put('/api/v3/config', fullConfig)
// Recargar URL pública por si cambió el puerto
const urlInfo = await api.get('/api/v3/whip/url')
state.basePublishUrl = urlInfo.data.base_publish_url
}
```
> **Importante**: usar siempre `PUT /api/v3/config` con el objeto completo. El Core no soporta PATCH parcial — si se envía solo `{ whip: {...} }`, los demás campos se resetean a defaults.
---
## Notas técnicas para el desarrollador de la UI
1. **Compatibilidad de OBS**: OBS 30+ soporta WHIP nativo. En Configuración → Transmisión, seleccionar "Servicio: Personalizado" y pegar la URL completa.
@ -407,3 +510,318 @@ El proceso comenzará a recibir video cuando OBS empiece a transmitir.
6. **Puertos**: Por defecto el WHIP server escucha en `:8555` (HTTP, sin TLS). El Core HTTP principal escucha en `:8080`. Son servidores independientes.
7. **Múltiples publishers**: El servidor WHIP soporta múltiples stream keys simultáneos. Cada stream key es independiente y genera su propio relay interno.
---
## Parte 6 — WHEP: reproducción en browser sin FFmpeg (nueva funcionalidad)
WHEP (WebRTC HTTP Egress Protocol) es el complemento de WHIP. Permite que un browser reciba el stream de OBS directamente con latencia ~300ms, **sin transcoding ni FFmpeg**. El Core actúa como SFU: recibe RTP de OBS y lo reenvía vía DTLS-SRTP a cada suscriptor.
### 6.1 Cambios en las APIs existentes
#### `GET /api/v3/whip` — campo nuevo: `subscribers`
```json
[
{
"name": "b89d39bb-5321-46f3-8d89-54a03150205d",
"published_at": "2026-03-14T20:09:09.87237418Z",
"subscribers": 2
}
]
```
Usar `subscribers` para mostrar el contador de viewers en tiempo real en el panel WHIP Server.
#### `GET /api/v3/whip/:name/url` — campo nuevo: `whep_url`
```json
{
"publish_url": "http://192.168.1.15:8555/whip/mystream",
"sdp_url": "http://localhost:8555/whip/mystream/sdp",
"stream_key": "mystream",
"whep_url": "http://192.168.1.15:8555/whip/mystream/whep"
}
```
#### `GET /api/v3/whip/url` — campo nuevo: `base_whep_url`
```json
{
"base_publish_url": "http://192.168.1.15:8555/whip/",
"base_sdp_url": "http://localhost:8555/whip/",
"base_whep_url": "http://192.168.1.15:8555/whip/",
"has_token": false,
"example_obs_url": "http://192.168.1.15:8555/whip/<stream-key>",
"input_address_template": "{whip:name=<stream-key>}"
}
```
La `whep_url` completa se construye como: `base_whep_url + stream_key + "/whep"`.
---
### 6.2 Nuevo endpoint WHEP
#### `POST /whip/:name/whep` — suscribirse al stream (en puerto 8555, **no 8080**)
El browser envía un SDP offer; el Core responde con SDP answer + ICE candidates.
> **IMPORTANTE**: Este endpoint vive en el servidor WHIP (puerto `:8555`), **no** en la API REST de Core (`:8080`). Llamarlo directamente desde el browser:
> ```
> POST http://192.168.1.15:8555/whip/mystream/whep
> ```
**Headers de request:**
```
Content-Type: application/sdp
Authorization: Bearer <token> (solo si has_token = true)
```
**Response `201 Created`:**
```
Content-Type: application/sdp
Location: /whip/mystream/whep/<subid>
Body: <SDP answer con ICE candidates>
```
**Response `404`** si no existe el stream key (canal no creado aún).
**Response `401`** si el token es requerido y no fue enviado.
#### `DELETE /whip/:name/whep/:subid` — cerrar suscripción
```
DELETE http://192.168.1.15:8555/whip/mystream/whep/<subid>
```
Donde `<subid>` es el último segmento del header `Location` devuelto en el POST.
---
### 6.3 Preview embed en el panel WHIP Server
Agregar un botón **"Ver en vivo"** / **"Preview"** en la tarjeta de cada stream activo. Al hacer clic:
```js
async function startWHEPPreview(streamKey, whepBaseUrl) {
const pc = new RTCPeerConnection({ iceServers: [] })
pc.addTransceiver('video', { direction: 'recvonly' })
pc.addTransceiver('audio', { direction: 'recvonly' })
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
const whepUrl = `${whepBaseUrl}${streamKey}/whep`
const headers = { 'Content-Type': 'application/sdp' }
if (token) headers['Authorization'] = `Bearer ${token}`
const resp = await fetch(whepUrl, {
method: 'POST',
headers,
body: offer.sdp,
})
if (!resp.ok) {
throw new Error(`WHEP error ${resp.status}`)
}
const locationHeader = resp.headers.get('Location')
const answerSDP = await resp.text()
await pc.setRemoteDescription({ type: 'answer', sdp: answerSDP })
// Mostrar video en el elemento <video>
pc.ontrack = (e) => {
if (e.track.kind === 'video') {
videoElement.srcObject = e.streams[0]
}
}
// Guardar para poder cerrar después
return { pc, locationHeader }
}
async function stopWHEPPreview(pc, locationHeader, whipServerBase) {
pc.close()
if (locationHeader) {
await fetch(whipServerBase + locationHeader, { method: 'DELETE' })
}
}
```
**Elemento HTML necesario:**
```html
<video id="whep-preview" autoplay playsinline muted controls></video>
```
> Usar `muted` inicialmente para evitar bloqueo de autoplay en Chrome/Firefox.
> Mostrar un botón de unmute para que el usuario active el audio voluntariamente.
---
### 6.4 CORS — el browser puede llamar directamente
El servidor WHIP responde con los headers CORS correctos en todos los endpoints:
```
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Methods: POST, DELETE, OPTIONS
Access-Control-Expose-Headers: Location
```
No se necesita proxy en la UI. El `fetch()` al puerto 8555 funciona desde el browser sin problemas de CORS.
---
### 6.5 Indicadores de estado en el panel
| Situación | Qué mostrar |
|---|---|
| OBS publicando, `subscribers: 0` | Badge "En vivo" + botón "Preview" |
| OBS publicando, `subscribers: N` | Badge "En vivo" + "N viewers" + botón "Preview activo" |
| OBS no publicando | Badge "Sin señal" — ocultar botón Preview |
| WHEP no soportado (token WebRTC no disponible) | No mostrar botón Preview |
Actualizar el contador de `subscribers` con el mismo polling de 5s de `GET /api/v3/whip`.
---
### 6.6 Tabla resumen de todos los endpoints WHEP/WHIP activos
| Endpoint | Puerto | Método | Descripción |
|---|---|---|---|
| `/api/v3/whip` | 8080 | GET | Lista publishers + contador subscribers |
| `/api/v3/whip/url` | 8080 | GET | URL base del servidor + `base_whep_url` |
| `/api/v3/whip/:name/url` | 8080 | GET | URLs completas: publish, sdp, **whep** |
| `/whip/:name/whep` | **8555** | POST | **Suscribirse vía WHEP** (desde browser) |
| `/whip/:name/whep/:subid` | **8555** | DELETE | Cerrar suscripción WHEP |
---
## Parte 7 — Multi-egress con `{whip-rtsp:name=X}` (relay RTSP interno)
### 7.1 Problema que resuelve
El placeholder `{whip:name=X}` (Parte 2) funciona bien para **un solo proceso FFmpeg** que consume el stream WHIP. Sin embargo, si se crean **dos o más procesos** que usan `{whip:name=X}` con el mismo stream key (por ejemplo, re-emitir a YouTube + Facebook + Twitch desde un solo OBS), solo uno puede leer el relay UDP loopback simultáneamente.
El relay RTSP interno resuelve esto: expone el stream como un servidor RTSP TCP al que pueden conectarse **N clientes FFmpeg en paralelo**, cada uno recibiendo una copia independiente.
```
OBS → WHIP → Core
├── {whip:name=X} → proceso: HLS/memfs (1 consumidor)
└── RTSP relay :8554
├── {whip-rtsp:name=X} → proceso: YouTube
├── {whip-rtsp:name=X} → proceso: Facebook
└── {whip-rtsp:name=X} → proceso: Twitch
```
### 7.2 Requisito en config
El relay RTSP debe estar habilitado en la configuración del Core:
```json
"whip": {
"enable": true,
"address": ":8555",
"token": "",
"rtsp_address": ":8554"
}
```
Si `rtsp_address` está vacío, el relay no arranca y `{whip-rtsp}` no funciona.
### 7.3 Placeholder de proceso
En el `input.address` de cualquier proceso de egress:
```
{whip-rtsp:name=<stream_key>}
```
El Core lo expande a `rtsp://127.0.0.1:8554/live/<stream_key>` e inyecta automáticamente `-rtsp_transport tcp` en las input options (para forzar RTP/AVP/TCP interleaved, el único modo soportado por el relay interno).
**Alternativa sin placeholder** (si la UI no usa el mecanismo de templates):
```json
{
"address": "rtsp://127.0.0.1:8554/live/mistream",
"options": ["-rtsp_transport", "tcp"]
}
```
En este caso la UI debe incluir `-rtsp_transport tcp` manualmente.
### 7.4 Diferencias con `{whip}`
| | `{whip:name=X}` | `{whip-rtsp:name=X}` |
|---|---|---|
| Protocolo | HTTP → SDP → UDP RTP | RTSP/TCP (interleaved) |
| Consumidores simultáneos | **1** | **N (ilimitado)** |
| Requiere `rtsp_address` en config | No | **Sí** |
| Auto-inyección de opciones FFmpeg | `-protocol_whitelist …` | `-rtsp_transport tcp` |
| Latencia adicional | Ninguna | Ninguna (~igual) |
| Uso típico | Proceso principal de grabación/HLS | Procesos de re-streaming a plataformas |
### 7.5 UI en el wizard de proceso — modo "WHIP Multi-Egress"
Cuando el usuario selecciona WHIP como fuente de entrada en un proceso, mostrar un **selector de modo**:
```
┌─────────────────────────────────────────────────────────────┐
│ Modo de acceso al stream WHIP │
│ │
│ ○ Directo — Un solo proceso lee el stream (recomendado │
│ para grabación/HLS mientras OBS transmite) │
│ │
│ ○ Relay RTSP — Múltiples procesos pueden leer │
│ simultáneamente (YouTube + Facebook + …) │
│ Requiere "Puerto relay RTSP" configurado │
└─────────────────────────────────────────────────────────────┘
```
**Modo Directo** → `input.address = "{whip:name=<stream_key>}"`
**Modo Relay RTSP** → `input.address = "{whip-rtsp:name=<stream_key>}"`
Si el modo Relay RTSP está seleccionado y `config.whip.rtsp_address` está vacío, mostrar un aviso:
> ⚠️ El relay RTSP no está configurado. Activalo en Configuración → WHIP Server → Puerto relay RTSP.
### 7.6 Creación de proceso via API
**Payload para proceso de re-streaming a YouTube usando relay RTSP:**
```json
{
"id": "obs-to-youtube",
"reference": "obs-to-youtube",
"input": [
{
"id": "in",
"address": "{whip-rtsp:name=mistream}",
"options": []
}
],
"output": [
{
"id": "out",
"address": "rtmp://a.rtmp.youtube.com/live2/STREAM_KEY",
"options": ["-c", "copy", "-f", "flv"]
}
],
"options": ["-loglevel", "level+info"]
}
```
> El Core expande `{whip-rtsp:name=mistream}``rtsp://127.0.0.1:8554/live/mistream`
> e inyecta `-rtsp_transport tcp` automáticamente en las input options.
**Tres procesos de egress simultáneos (YouTube + Facebook + Twitch):**
```json
// proceso 1
{ "input": [{ "address": "{whip-rtsp:name=mistream}" }], "output": [{ "address": "rtmp://youtube..." }] }
// proceso 2
{ "input": [{ "address": "{whip-rtsp:name=mistream}" }], "output": [{ "address": "rtmp://facebook..." }] }
// proceso 3
{ "input": [{ "address": "{whip-rtsp:name=mistream}" }], "output": [{ "address": "rtmp://twitch..." }] }
```
Los tres leen del relay RTSP independientemente; OBS solo transmite una vez.
### 7.7 Tabla resumen de endpoints actualizada
| Endpoint | Puerto | Método | Descripción |
|---|---|---|---|
| `/api/v3/whip` | 8080 | GET | Lista publishers + subscribers |
| `/api/v3/whip/url` | 8080 | GET | URL base + `base_whep_url` |
| `/api/v3/whip/:name/url` | 8080 | GET | URLs completas: publish, sdp, whep |
| `/whip/:name` | **8555** | POST | Publicar stream WHIP (OBS/clientes) |
| `/whip/:name/sdp` | **8555** | GET | SDP para FFmpeg (modo directo) |
| `/whip/:name/whep` | **8555** | POST | Suscribirse vía WHEP (browser) |
| `/whip/:name/whep/:subid` | **8555** | DELETE | Cerrar suscripción WHEP |
| `rtsp://localhost/live/:name` | **8554** | — | Relay RTSP multi-consumer (TCP only) |

View File

@ -984,6 +984,7 @@ class Restreamer {
if (override && val) {
const base = override.replace(/\/$/, '') + '/whip/';
val.base_publish_url = base;
val.base_whep_url = base;
val.example_obs_url = base + '<stream-key>';
}
@ -1001,6 +1002,7 @@ class Restreamer {
if (override && val) {
const base = override.replace(/\/$/, '') + '/whip/';
val.publish_url = base + name;
val.whep_url = base + name + '/whep';
}
return val;

View File

@ -1,10 +1,14 @@
import React from 'react';
import { Trans } from '@lingui/macro';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
import Grid from '@mui/material/Grid';
import Icon from '@mui/icons-material/CloudUpload';
import PeopleIcon from '@mui/icons-material/People';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import StopIcon from '@mui/icons-material/Stop';
import Typography from '@mui/material/Typography';
import BoxTextarea from '../../../../misc/BoxTextarea';
@ -14,20 +18,26 @@ function Source(props) {
const channelid = props.config?.channelid || 'external';
const configWhip = props.config?.whip || {};
// Live state fetched from Core on every mount — never trust the cached session config.
// null = still loading
// false = WHIP disabled or Core returned an error
// object = { base_publish_url, has_token, ... } (from GET /v3/whip/url)
// object = { base_publish_url, base_whep_url, has_token, ... }
const [whipInfo, setWhipInfo] = React.useState(null);
const [obsUrl, setObsUrl] = React.useState('');
const [whepUrl, setWhepUrl] = React.useState('');
const [bearerToken, setBearerToken] = React.useState('');
const [subscribers, setSubscribers] = React.useState(0);
const [active, setActive] = React.useState(false);
// WHEP preview state
const [previewActive, setPreviewActive] = React.useState(false);
const [previewError, setPreviewError] = React.useState('');
const pcRef = React.useRef(null);
const locationRef = React.useRef(null);
const videoRef = React.useRef(null);
const streamRef = React.useRef(null); // MediaStream across re-renders
// ── 1. Load live WHIP state from Core ──────────────────────────────────
const loadWhipInfo = React.useCallback(async () => {
// Helper: build whipInfo from the cached session config as a fallback.
// Used when the Core doesn't expose /v3/whip/url (older Core versions)
// or when WhipUrl() fails for any transient reason.
const fromSessionConfig = () =>
configWhip.enabled === true
? { base_publish_url: `http://${configWhip.host}/whip/`, has_token: !!configWhip.token }
@ -35,11 +45,8 @@ function Source(props) {
if (props.restreamer?.WhipUrl) {
const info = await props.restreamer.WhipUrl();
// info === null means the endpoint returned an error (e.g. 404 on older
// Core builds that don't expose /v3/whip/url yet). Fall back gracefully.
setWhipInfo(info !== null ? info : fromSessionConfig());
} else {
// No authenticated API available — use cached session config directly.
setWhipInfo(fromSessionConfig());
}
}, [props.restreamer, configWhip.enabled, configWhip.host, configWhip.token]);
@ -49,42 +56,47 @@ function Source(props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── 2. Build OBS URL once whipInfo is available ────────────────────────
// ── 2. Build OBS + WHEP URLs ────────────────────────────────────────────
React.useEffect(() => {
if (!whipInfo) {
setObsUrl('');
setWhepUrl('');
setBearerToken('');
return;
}
// OBS WHIP has two separate fields:
// • Server → the WHIP endpoint URL (NO token in URL)
// • Bearer Token → the token value (sent as Authorization: Bearer header)
if (props.restreamer?.WhipStreamUrl) {
props.restreamer.WhipStreamUrl(channelid).then((data) => {
if (data?.publish_url) {
// Strip any ?token=... query param — token goes in Bearer Token field
const clean = data.publish_url.split('?')[0];
setObsUrl(clean);
setObsUrl(data.publish_url.split('?')[0]);
} else {
const base = whipInfo.base_publish_url || `http://${configWhip.host}/whip/`;
setObsUrl(`${base}${channelid}`);
}
if (data?.whep_url) {
setWhepUrl(data.whep_url);
} else {
const base = whipInfo.base_whep_url || whipInfo.base_publish_url || `http://${configWhip.host}/whip/`;
setWhepUrl(`${base}${channelid}/whep`);
}
setBearerToken(configWhip.token || '');
});
} else {
const base = whipInfo.base_publish_url || `http://${configWhip.host}/whip/`;
setObsUrl(`${base}${channelid}`);
setWhepUrl(`${base}${channelid}/whep`);
setBearerToken(configWhip.token || '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [whipInfo, channelid]);
// ── 3. Poll active streams ─────────────────────────────────────────────
// ── 3. Poll active streams + subscriber count ───────────────────────────
const pollWhipStreams = React.useCallback(async () => {
if (!props.restreamer) return;
try {
const streams = await props.restreamer.WhipStreams();
setActive(streams.some((s) => s.name === channelid));
const stream = streams.find((s) => s.name === channelid);
setActive(!!stream);
setSubscribers(stream?.subscribers ?? 0);
} catch (_) {}
}, [props.restreamer, channelid]);
@ -95,19 +107,104 @@ function Source(props) {
return () => clearInterval(id);
}, [whipInfo, pollWhipStreams, props.restreamer]);
// ── 4. Notify parent whenever enabled state resolves ──────────────────
// ── 4. Notify parent ───────────────────────────────────────────────────
React.useEffect(() => {
if (whipInfo === null) return; // still loading — don't fire yet
if (whipInfo === null) return;
const enabled = !!whipInfo;
const inputs = [{ address: `{whip:name=${channelid}}`, options: [] }];
props.onChange('network', { mode: 'push', push: { type: 'whip', name: channelid } }, inputs, enabled);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [whipInfo, channelid]);
// Still loading — render nothing to avoid flicker
if (whipInfo === null) {
return null;
// ── 5. WHEP preview ────────────────────────────────────────────────────
const startPreview = React.useCallback(async () => {
setPreviewError('');
try {
const pc = new RTCPeerConnection({ iceServers: [] });
// ontrack BEFORE createOffer — tracks fire as soon as ICE+DTLS completes
pc.ontrack = (e) => {
if (!e.streams?.[0]) return;
streamRef.current = e.streams[0];
if (videoRef.current) {
videoRef.current.srcObject = e.streams[0];
videoRef.current.play().catch(() => {});
}
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Wait for ICE gathering to complete (max 4s) before POSTing
if (pc.iceGatheringState !== 'complete') {
await new Promise((resolve) => {
const tid = setTimeout(resolve, 4000);
const handler = () => {
if (pc.iceGatheringState === 'complete') {
clearTimeout(tid);
pc.removeEventListener('icegatheringstatechange', handler);
resolve();
}
};
pc.addEventListener('icegatheringstatechange', handler);
});
}
const headers = { 'Content-Type': 'application/sdp' };
if (bearerToken) headers['Authorization'] = `Bearer ${bearerToken}`;
// Use pc.localDescription.sdp — now contains gathered ICE candidates
const resp = await fetch(whepUrl, { method: 'POST', headers, body: pc.localDescription.sdp });
if (!resp.ok) {
throw new Error(`WHEP ${resp.status}`);
}
locationRef.current = resp.headers.get('Location');
const answerSDP = await resp.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answerSDP });
pcRef.current = pc;
setPreviewActive(true);
} catch (err) {
setPreviewError(err.message);
}
}, [whepUrl, bearerToken]);
// Apply stream after previewActive→true causes re-render and videoRef becomes available
React.useEffect(() => {
if (previewActive && videoRef.current && streamRef.current) {
videoRef.current.srcObject = streamRef.current;
videoRef.current.play().catch(() => {});
}
}, [previewActive]);
const stopPreview = React.useCallback(async () => {
if (pcRef.current) {
pcRef.current.close();
pcRef.current = null;
}
if (locationRef.current && whepUrl) {
const base = whepUrl.replace(/\/whep$/, '');
const deleteUrl = base + locationRef.current;
try { await fetch(deleteUrl, { method: 'DELETE' }); } catch (_) {}
locationRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setPreviewActive(false);
}, [whepUrl]);
// Stop preview on unmount
React.useEffect(() => {
return () => { if (pcRef.current) { pcRef.current.close(); } };
}, []);
// ── Render ─────────────────────────────────────────────────────────────
if (whipInfo === null) return null;
if (!whipInfo) {
return (
@ -145,14 +242,47 @@ function Source(props) {
</Grid>
)}
{props.restreamer && (
<Grid item xs={12}>
<React.Fragment>
<Grid item xs={12} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{active ? (
<React.Fragment>
<Chip
icon={<FiberManualRecordIcon style={{ color: '#27ae60' }} />}
label={<Trans>Live</Trans>}
size="small"
style={{ backgroundColor: 'rgba(39,174,96,0.15)', color: '#27ae60', border: '1px solid #27ae60' }}
/>
{subscribers > 0 && (
<Chip
icon={<PeopleIcon style={{ fontSize: 14 }} />}
label={subscribers}
size="small"
style={{ backgroundColor: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.7)' }}
/>
)}
{!previewActive ? (
<Button
size="small"
variant="outlined"
startIcon={<PlayArrowIcon />}
onClick={startPreview}
style={{ marginLeft: 'auto' }}
>
<Trans>Preview</Trans>
</Button>
) : (
<Button
size="small"
variant="outlined"
color="secondary"
startIcon={<StopIcon />}
onClick={stopPreview}
style={{ marginLeft: 'auto' }}
>
<Trans>Stop preview</Trans>
</Button>
)}
</React.Fragment>
) : (
<Chip
icon={<FiberManualRecordIcon style={{ color: 'rgba(255,255,255,0.3)' }} />}
@ -162,6 +292,25 @@ function Source(props) {
/>
)}
</Grid>
{previewError && (
<Grid item xs={12}>
<Typography variant="caption" color="error">{previewError}</Typography>
</Grid>
)}
{/* Always render <video> while Live so videoRef is in DOM when ontrack fires */}
{active && (
<Grid item xs={12} style={{ display: previewActive ? 'block' : 'none' }}>
<video
ref={videoRef}
autoPlay
playsInline
muted
controls
style={{ width: '100%', borderRadius: 4, background: '#000' }}
/>
</Grid>
)}
</React.Fragment>
)}
</React.Fragment>
);

View File

@ -66,6 +66,7 @@ function makeRestreamer(activeStreams = [], whipEnabled = true) {
WhipStreamUrl: jest.fn().mockImplementation((name) =>
Promise.resolve({
publish_url: `http://192.168.1.15:8555/whip/${name}`,
whep_url: `http://192.168.1.15:8555/whip/${name}/whep`,
sdp_url: `http://localhost:8555/whip/${name}/sdp`,
stream_key: name,
}),
@ -192,6 +193,22 @@ test('whip:onChange — emits enabled=true (from session config fallback) when W
expect(lastCall[3]).toBe(true);
});
test('whip:whep — shows whep_url from WhipStreamUrl response', async () => {
const restreamer = makeRestreamer([], true);
await act(async () => {
render(<Source config={configEnabled} restreamer={restreamer} onChange={jest.fn()} />);
});
await act(async () => { await Promise.resolve(); });
await act(async () => { await Promise.resolve(); });
// The WHEP URL is stored in state but not rendered as text in the DOM
// (it's used by the Preview button). Verify WhipStreamUrl was called and
// returned the expected whep_url by checking the OBS URL field is correct.
expect(restreamer.WhipStreamUrl).toHaveBeenCalledWith('my-channel');
expect(screen.getByDisplayValue('http://192.168.1.15:8555/whip/my-channel')).toBeInTheDocument();
});
test('whip:live — shows "Live" chip when stream is active', async () => {
const restreamer = makeRestreamer([{ name: 'my-channel', published_at: '2026-03-14T00:00:00Z' }], true);
@ -205,6 +222,33 @@ test('whip:live — shows "Live" chip when stream is active', async () => {
expect(restreamer.WhipStreams).toHaveBeenCalledTimes(1);
});
test('whip:subscribers — shows subscriber count chip when subscribers > 0', async () => {
const restreamer = makeRestreamer([{ name: 'my-channel', published_at: '2026-03-14T00:00:00Z', subscribers: 3 }], true);
await act(async () => {
render(<Source config={configEnabled} restreamer={restreamer} onChange={jest.fn()} />);
});
await act(async () => { await Promise.resolve(); });
await act(async () => { await Promise.resolve(); });
expect(screen.getByText('Live')).toBeInTheDocument();
// subscriber count chip shows the number
expect(screen.getByText('3')).toBeInTheDocument();
});
test('whip:subscribers — no subscriber chip when subscribers is 0', async () => {
const restreamer = makeRestreamer([{ name: 'my-channel', published_at: '2026-03-14T00:00:00Z', subscribers: 0 }], true);
await act(async () => {
render(<Source config={configEnabled} restreamer={restreamer} onChange={jest.fn()} />);
});
await act(async () => { await Promise.resolve(); });
await act(async () => { await Promise.resolve(); });
expect(screen.getByText('Live')).toBeInTheDocument();
expect(screen.queryByText('0')).not.toBeInTheDocument();
});
test('whip:waiting — shows "Waiting" chip when no active publisher', async () => {
const restreamer = makeRestreamer([], true); // WhipUrl enabled, no active streams
@ -247,3 +291,29 @@ test('whip:restart-recovery — re-mounting fetches fresh WhipUrl from Core', as
expect(screen.getByDisplayValue('http://192.168.1.15:8555/whip/my-channel')).toBeInTheDocument();
});
test('whip:preview-button — Preview button visible only when Live and whepUrl available', async () => {
const restreamer = makeRestreamer([{ name: 'my-channel', published_at: '2026-03-14T00:00:00Z' }], true);
await act(async () => {
render(<Source config={configEnabled} restreamer={restreamer} onChange={jest.fn()} />);
});
await act(async () => { await Promise.resolve(); });
await act(async () => { await Promise.resolve(); });
// When stream is Live and whepUrl is available, Preview button should be rendered
expect(screen.getByText('Live')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /preview/i })).toBeInTheDocument();
});
test('whip:preview-button — no Preview button when stream is not live', async () => {
const restreamer = makeRestreamer([], true); // no active streams
await act(async () => {
render(<Source config={configEnabled} restreamer={restreamer} onChange={jest.fn()} />);
});
await act(async () => { await Promise.resolve(); });
await act(async () => { await Promise.resolve(); });
expect(screen.queryByRole('button', { name: /preview/i })).not.toBeInTheDocument();
});

View File

@ -12,6 +12,8 @@ import WarningIcon from '@mui/icons-material/Warning';
import ScreenShareIcon from '@mui/icons-material/ScreenShare';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import StopIcon from '@mui/icons-material/Stop';
import Button from '@mui/material/Button';
import * as M from '../../utils/metadata';
@ -79,6 +81,23 @@ const useStyles = makeStyles((theme) => ({
marginRight: 6,
animation: '$pulse 1.2s ease-in-out infinite',
},
whepVideo: {
position: 'absolute',
top: 0, left: 0,
width: '100%', height: '100%',
background: '#000',
objectFit: 'contain',
},
whepOverlay: {
position: 'absolute',
top: 0, left: 0, bottom: 0, right: 0,
backgroundColor: theme.palette.common.black,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
'@keyframes pulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.3 },
@ -104,6 +123,20 @@ export default function Main(props) {
log: [],
},
});
// WHIP/WHEP source state
const [$whipSource, setWhipSource] = React.useState({
active: false,
whepUrl: '',
bearerToken: '',
previewActive: false,
previewError: '',
whepConnState: 'idle', // idle | gathering | connecting | live | error
});
const whepPcRef = React.useRef(null);
const whepLocationRef = React.useRef(null);
const whepVideoRef = React.useRef(null);
const whepStreamRef = React.useRef(null); // holds MediaStream across re-renders
// WebRTC Room detection
const [$webrtcRoom, setWebrtcRoom] = React.useState({
active: false, // source type = webrtcroom
@ -171,6 +204,10 @@ export default function Main(props) {
// Detect if the video source is a WebRTC Room
const videoSource = metadata.sources && metadata.sources[0];
const isWhip = videoSource?.type === 'network'
&& videoSource?.settings?.mode === 'push'
&& videoSource?.settings?.push?.type === 'whip';
if (videoSource && videoSource.type === 'webrtcroom') {
const settings = videoSource.settings || {};
const roomId = settings.roomId || _channelid;
@ -182,8 +219,24 @@ export default function Main(props) {
roomId,
copied: false,
});
setWhipSource({ active: false, whepUrl: '', bearerToken: '', previewActive: false, previewError: '' });
} else if (isWhip) {
const name = videoSource.settings.push.name || _channelid;
const token = config?.source?.network?.whip?.token || '';
let whepUrl = '';
if (props.restreamer.WhipStreamUrl) {
const data = await props.restreamer.WhipStreamUrl(name);
whepUrl = data?.whep_url || '';
}
if (!whepUrl) {
const whipHost = config?.source?.network?.whip?.host || 'localhost:8555';
whepUrl = `http://${whipHost}/whip/${name}/whep`;
}
setWhipSource({ active: true, whepUrl, bearerToken: token, previewActive: false, previewError: '' });
setWebrtcRoom({ active: false, roomUrl: '', roomId: '', copied: false });
} else {
setWebrtcRoom({ active: false, roomUrl: '', roomId: '', copied: false });
setWhipSource({ active: false, whepUrl: '', bearerToken: '', previewActive: false, previewError: '' });
}
await update();
@ -333,6 +386,135 @@ export default function Main(props) {
});
};
// ── WHEP preview ─────────────────────────────────────────────────────────
const startWhepPreview = React.useCallback(async () => {
setWhipSource((prev) => ({ ...prev, previewError: '', whepConnState: 'gathering' }));
try {
const pc = new RTCPeerConnection({ iceServers: [] });
// ① ontrack set BEFORE createOffer — tracks fire as soon as ICE+DTLS finishes
pc.ontrack = (e) => {
if (!e.streams?.[0]) return;
whepStreamRef.current = e.streams[0];
if (whepVideoRef.current) {
whepVideoRef.current.srcObject = e.streams[0];
whepVideoRef.current.play().catch(() => {});
}
};
// ICE failure feedback
pc.oniceconnectionstatechange = () => {
const s = pc.iceConnectionState;
if (s === 'connected' || s === 'completed') {
setWhipSource((prev) => ({ ...prev, whepConnState: 'live' }));
} else if (s === 'failed') {
setWhipSource((prev) => ({
...prev,
whepConnState: 'error',
previewActive: false,
previewError: 'ICE failed — check that port 8555 UDP is reachable from this browser',
}));
} else if (s === 'disconnected') {
setWhipSource((prev) => ({ ...prev, whepConnState: 'idle', previewActive: false }));
}
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// ② Wait for ICE gathering to complete (max 4 s) before POSTing.
// This ensures local candidates are included in the offer SDP,
// which is required for Cores ICE-lite WHEP server to reach the browser.
if (pc.iceGatheringState !== 'complete') {
await new Promise((resolve) => {
const tid = setTimeout(resolve, 4000);
const handler = () => {
if (pc.iceGatheringState === 'complete') {
clearTimeout(tid);
pc.removeEventListener('icegatheringstatechange', handler);
resolve();
}
};
pc.addEventListener('icegatheringstatechange', handler);
});
}
setWhipSource((prev) => ({ ...prev, whepConnState: 'connecting' }));
const headers = { 'Content-Type': 'application/sdp' };
if ($whipSource.bearerToken) headers['Authorization'] = `Bearer ${$whipSource.bearerToken}`;
// ③ Use pc.localDescription.sdp — now contains gathered ICE candidates
const resp = await fetch($whipSource.whepUrl, {
method: 'POST', headers, body: pc.localDescription.sdp,
});
if (!resp.ok) throw new Error(`WHEP ${resp.status}`);
whepLocationRef.current = resp.headers.get('Location');
const answerSDP = await resp.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answerSDP });
whepPcRef.current = pc;
setWhipSource((prev) => ({ ...prev, previewActive: true }));
} catch (err) {
setWhipSource((prev) => ({ ...prev, whepConnState: 'idle', previewError: err.message }));
}
}, [$whipSource.whepUrl, $whipSource.bearerToken]);
// ② After previewActive → true React renders <video>. Apply the stream
// that may have arrived (ontrack) before the DOM node existed.
React.useEffect(() => {
if ($whipSource.previewActive && whepVideoRef.current && whepStreamRef.current) {
whepVideoRef.current.srcObject = whepStreamRef.current;
whepVideoRef.current.play().catch(() => {});
}
}, [$whipSource.previewActive]);
const stopWhepPreview = React.useCallback(async () => {
if (whepPcRef.current) {
whepPcRef.current.close();
whepPcRef.current = null;
}
if (whepLocationRef.current && $whipSource.whepUrl) {
const base = $whipSource.whepUrl.replace(/\/whep$/, '');
const deleteUrl = base + whepLocationRef.current;
try { await fetch(deleteUrl, { method: 'DELETE' }); } catch (_) {}
whepLocationRef.current = null;
}
if (whepVideoRef.current) {
whepVideoRef.current.srcObject = null;
}
whepStreamRef.current = null;
setWhipSource((prev) => ({ ...prev, previewActive: false, whepConnState: 'idle', previewError: '' }));
}, [$whipSource.whepUrl]);
// Stop preview if stream goes offline
React.useEffect(() => {
if ($state.state !== 'connected' && $whipSource.previewActive) {
stopWhepPreview();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [$state.state]);
// Cleanup on unmount
React.useEffect(() => {
return () => { if (whepPcRef.current) whepPcRef.current.close(); };
}, []);
// ── Go Live: start ingest + all configured egresses ───────────────────────
const connectAndStartPublications = React.useCallback(async () => {
await connect();
// Give FFmpeg a moment to start reading the WHIP source before launching egresses
setTimeout(async () => {
try {
const processes = await props.restreamer.ListIngestEgresses(_channelid);
for (const p of processes) {
if (p.service === 'player') continue;
try { await props.restreamer.StartEgress(_channelid, p.id); } catch (_) {}
}
} catch (_) {}
}, 3000);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [_channelid, props.restreamer]);
const handleHelp = (topic) => () => {
H(topic);
};
@ -416,8 +598,92 @@ export default function Main(props) {
<Grid item xs={12}>
<Grid container spacing={0} className={classes.playerL1}>
<Grid item xs={12} className={classes.playerL2}>
{/* ── WebRTC Room source ── */}
{$webrtcRoom.active ? (
{/* ── WHIP source → WHEP real-time preview ── */}
{$whipSource.active ? (
<React.Fragment>
{($state.state === 'disconnected' || $state.state === 'disconnecting') && (
<Grid container direction="column" className={classes.playerL3} justifyContent="center" alignItems="center" spacing={1}>
<Grid item><Typography variant="h2"><Trans>No video</Trans></Typography></Grid>
</Grid>
)}
{$state.state === 'connecting' && (
<Grid container direction="column" className={classes.playerL3} justifyContent="center" alignItems="center" spacing={1}>
<Grid item><CircularProgress color="inherit" /></Grid>
<Grid item><Typography><Trans>Connecting ...</Trans></Typography></Grid>
</Grid>
)}
{$state.state === 'error' && (
<Grid container direction="column" className={classes.playerL3} justifyContent="center" alignItems="center" spacing={1}>
<Grid item><WarningIcon className={classes.playerWarningIcon} /></Grid>
<Grid item>
<Typography><Trans>Error: {anonymize($state.progress.error) || 'unknown'}</Trans></Typography>
</Grid>
</Grid>
)}
{$state.state === 'connected' && (
<React.Fragment>
{/*
Video always in DOM (no display:none) so whepVideoRef is
set and autoPlay works reliably across all browsers.
The whepOverlay sits on top until the stream is live.
*/}
<video
ref={whepVideoRef}
autoPlay
playsInline
muted
controls
className={classes.whepVideo}
/>
{!$whipSource.previewActive && (
<div className={classes.whepOverlay}>
{($whipSource.whepConnState === 'gathering' || $whipSource.whepConnState === 'connecting') ? (
<React.Fragment>
<CircularProgress color="inherit" size={36} />
<Typography variant="caption" style={{ color: 'rgba(255,255,255,0.6)', marginTop: 8 }}>
{$whipSource.whepConnState === 'gathering'
? <Trans>Gathering network candidates</Trans>
: <Trans>Connecting via WebRTC</Trans>}
</Typography>
</React.Fragment>
) : (
<React.Fragment>
<Button
variant="contained"
startIcon={<PlayArrowIcon />}
onClick={startWhepPreview}
style={{ background: '#27ae60' }}
>
<Trans>Live Preview</Trans>
</Button>
<Typography variant="caption" style={{ color: 'rgba(255,255,255,0.4)' }}>
<Trans>Real-time via WHEP (~300ms)</Trans>
</Typography>
{$whipSource.previewError && (
<Typography variant="caption" color="error" style={{ maxWidth: 300, textAlign: 'center' }}>
{$whipSource.previewError}
</Typography>
)}
</React.Fragment>
)}
</div>
)}
{$whipSource.previewActive && (
<Button
size="small"
variant="contained"
startIcon={<StopIcon />}
onClick={stopWhepPreview}
style={{ position: 'absolute', top: 8, right: 8, zIndex: 10, opacity: 0.85 }}
>
<Trans>Stop</Trans>
</Button>
)}
</React.Fragment>
)}
</React.Fragment>
) : $webrtcRoom.active ? (
/* ── WebRTC Room source ── */
$webrtcRoom.relayActive && $state.state === 'connected' ? (
/* Relay activo → mostrar HLS preview normal */
<Player type="videojs-internal" source={manifest} poster={poster} autoplay mute controls />
@ -594,6 +860,19 @@ export default function Main(props) {
</Stack>
</Grid>
<Grid item xs={12} marginTop="0em">
{$whipSource.active && ($state.state === 'disconnected' || $state.state === 'disconnecting') ? (
/* For WHIP sources: single "Go Live" button starts FFmpeg + all egresses */
<Button
fullWidth
variant="contained"
size="large"
startIcon={<PlayArrowIcon />}
onClick={connectAndStartPublications}
style={{ background: '#27ae60', color: '#fff', fontWeight: 700 }}
>
<Trans>Go Live</Trans>
</Button>
) : (
<ActionButton
order={$state.order}
state={$state.state}
@ -602,6 +881,7 @@ export default function Main(props) {
onConnect={connect}
onReconnect={reconnect}
/>
)}
</Grid>
<Grid item xs={12} textAlign="right">
<Link variant="body2" color="textSecondary" href="#!" onClick={handleProcessDetails} className={classes.link}>