feat: add WHIP-RTSP support for multi-platform streaming
- Introduced WHIP-RTSP as a new source option in Source.js. - Enhanced Restreamer to handle RTSP relay configuration and address parsing. - Updated WebRTCRoom to allow selection between direct and RTSP relay modes. - Implemented WHIPPublications component for managing multiple WHIP destinations. - Added internal RTSP relay configuration in Settings.js. - Improved handling of WHEP and WHIP streams, including error logging and state management.
This commit is contained in:
parent
89446e701f
commit
5c586a2aa3
@ -1,8 +1,9 @@
|
||||
REACT_APP_CORE_URL=http://192.168.1.15:8080
|
||||
REACT_APP_WHIP_BASE_URL=http://192.168.1.15:8555
|
||||
REACT_APP_YTDLP_URL=http://100.73.244.28:8080
|
||||
REACT_APP_YTDLP_URL=http://192.168.1.20:8282
|
||||
REACT_APP_FB_SERVER_URL=http://localhost:3002
|
||||
REACT_APP_LIVEKIT_API_KEY=APIBTqTGxf9htMK
|
||||
REACT_APP_LIVEKIT_API_SECRET=0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW
|
||||
REACT_APP_LIVEKIT_WS_URL=wss://livekit-server.nextream.sytes.net
|
||||
REACT_APP_WHIP_SERVER_URL=https://djmaster.nextream.sytes.net
|
||||
C
|
||||
@ -600,6 +600,17 @@ Agregar un botón **"Ver en vivo"** / **"Preview"** en la tarjeta de cada stream
|
||||
async function startWHEPPreview(streamKey, whepBaseUrl) {
|
||||
const pc = new RTCPeerConnection({ iceServers: [] })
|
||||
|
||||
// Set up the MediaStream BEFORE creating the offer so that ontrack fires
|
||||
// correctly regardless of how fast ICE negotiation completes.
|
||||
// Using a MediaStream instead of e.streams[0] avoids the common bug where
|
||||
// e.streams is empty (no a=msid in SDP) and srcObject never gets set.
|
||||
const mediaStream = new MediaStream()
|
||||
videoElement.srcObject = mediaStream
|
||||
|
||||
pc.ontrack = (e) => {
|
||||
mediaStream.addTrack(e.track)
|
||||
}
|
||||
|
||||
pc.addTransceiver('video', { direction: 'recvonly' })
|
||||
pc.addTransceiver('audio', { direction: 'recvonly' })
|
||||
|
||||
@ -627,11 +638,8 @@ async function startWHEPPreview(streamKey, whepBaseUrl) {
|
||||
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]
|
||||
}
|
||||
}
|
||||
// NOTA: ontrack ya está registrado arriba, los tracks se agregan a mediaStream automáticamente.
|
||||
// El elemento <video> ya tiene srcObject = mediaStream desde el inicio.
|
||||
|
||||
// Guardar para poder cerrar después
|
||||
return { pc, locationHeader }
|
||||
@ -825,3 +833,398 @@ Los tres leen del relay RTSP independientemente; OBS solo transmite una vez.
|
||||
| `/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) |
|
||||
|
||||
---
|
||||
|
||||
## Parte 8 — Panel "Publicaciones" (re-streaming a redes sociales)
|
||||
|
||||
### 8.1 Concepto general
|
||||
|
||||
El usuario transmite **una sola vez desde OBS** hacia el Core (WHIP). El Core re-emite el stream a **todas las plataformas configuradas** en paralelo (YouTube, Twitch, Facebook, TikTok, etc.). Cada destino es un **proceso FFmpeg independiente** que lee del relay RTSP interno.
|
||||
|
||||
```
|
||||
OBS ──WHIP──► Core RTSP relay :8554
|
||||
├──► proceso "YouTube" → rtmp://a.rtmp.youtube.com/live2/<key>
|
||||
├──► proceso "Twitch" → rtmp://live.twitch.tv/app/<key>
|
||||
├──► proceso "Facebook" → rtmps://live-api-s.facebook.com:443/rtmp/<key>
|
||||
└──► proceso "TikTok" → rtmp://...
|
||||
```
|
||||
|
||||
Cada proceso se crea con `POST /api/v3/process` y puede iniciarse/detenerse individualmente.
|
||||
|
||||
---
|
||||
|
||||
### 8.2 ⚠️ Limitación crítica de audio — Opus → AAC obligatorio
|
||||
|
||||
OBS envía audio **Opus** (48 kHz, estéreo) via WHIP. El protocolo **RTMP/FLV** (que usan YouTube, Twitch, Facebook y TikTok) **no soporta Opus**; solo acepta **AAC** o MP3.
|
||||
|
||||
Por lo tanto, en todos los procesos de publicación a redes sociales:
|
||||
- **Video**: `-c:v copy` ✅ — H264 pasa sin recodificación
|
||||
- **Audio**: `-c:a aac -b:a 128k` ✅ — Opus se transcodifica a AAC en tiempo real
|
||||
- **❌ `-c:a copy` está PROHIBIDO** para salidas RTMP — el stream llegaría corrupto o mudo
|
||||
|
||||
El FFmpeg interno del Core ejecuta el proceso equivalente a:
|
||||
```bash
|
||||
ffmpeg -rtsp_transport tcp \
|
||||
-i rtsp://127.0.0.1:8554/live/<stream_key> \
|
||||
-c:v copy \
|
||||
-c:a aac -b:a 128k \
|
||||
-f flv \
|
||||
rtmp://a.rtmp.youtube.com/live2/<youtube_key>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8.3 Payload de proceso por plataforma
|
||||
|
||||
#### YouTube
|
||||
```json
|
||||
{
|
||||
"id": "pub-youtube",
|
||||
"reference": "pub-youtube",
|
||||
"input": [
|
||||
{
|
||||
"id": "in",
|
||||
"address": "{whip-rtsp:name=<stream_key>}",
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"output": [
|
||||
{
|
||||
"id": "out",
|
||||
"address": "rtmp://a.rtmp.youtube.com/live2/<YOUTUBE_STREAM_KEY>",
|
||||
"options": ["-c:v", "copy", "-c:a", "aac", "-b:a", "128k", "-f", "flv"]
|
||||
}
|
||||
],
|
||||
"options": ["-loglevel", "level+info"],
|
||||
"reconnect": true,
|
||||
"reconnect_delay_seconds": 5,
|
||||
"autostart": false
|
||||
}
|
||||
```
|
||||
|
||||
#### Twitch
|
||||
```json
|
||||
{
|
||||
"id": "pub-twitch",
|
||||
"reference": "pub-twitch",
|
||||
"input": [
|
||||
{
|
||||
"id": "in",
|
||||
"address": "{whip-rtsp:name=<stream_key>}",
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"output": [
|
||||
{
|
||||
"id": "out",
|
||||
"address": "rtmp://live.twitch.tv/app/<TWITCH_STREAM_KEY>",
|
||||
"options": ["-c:v", "copy", "-c:a", "aac", "-b:a", "128k", "-f", "flv"]
|
||||
}
|
||||
],
|
||||
"options": ["-loglevel", "level+info"],
|
||||
"reconnect": true,
|
||||
"reconnect_delay_seconds": 5,
|
||||
"autostart": false
|
||||
}
|
||||
```
|
||||
|
||||
#### Facebook Live (RTMPS — requiere soporte TLS en FFmpeg)
|
||||
```json
|
||||
{
|
||||
"id": "pub-facebook",
|
||||
"reference": "pub-facebook",
|
||||
"input": [
|
||||
{
|
||||
"id": "in",
|
||||
"address": "{whip-rtsp:name=<stream_key>}",
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"output": [
|
||||
{
|
||||
"id": "out",
|
||||
"address": "rtmps://live-api-s.facebook.com:443/rtmp/<FACEBOOK_STREAM_KEY>",
|
||||
"options": ["-c:v", "copy", "-c:a", "aac", "-b:a", "128k", "-f", "flv"]
|
||||
}
|
||||
],
|
||||
"options": ["-loglevel", "level+info"],
|
||||
"reconnect": true,
|
||||
"reconnect_delay_seconds": 5,
|
||||
"autostart": false
|
||||
}
|
||||
```
|
||||
> ⚠️ Facebook e Instagram usan **RTMPS** (RTMP sobre TLS, puerto 443). Verificar que el binario FFmpeg del Core esté compilado con `--enable-openssl` o `--enable-gnutls`. Si no, el proceso fallará con `Protocol not found`.
|
||||
|
||||
#### TikTok Live
|
||||
```json
|
||||
{
|
||||
"id": "pub-tiktok",
|
||||
"reference": "pub-tiktok",
|
||||
"input": [
|
||||
{
|
||||
"id": "in",
|
||||
"address": "{whip-rtsp:name=<stream_key>}",
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"output": [
|
||||
{
|
||||
"id": "out",
|
||||
"address": "rtmp://<TIKTOK_SERVER_URL>/<TIKTOK_STREAM_KEY>",
|
||||
"options": ["-c:v", "copy", "-c:a", "aac", "-b:a", "128k", "-f", "flv"]
|
||||
}
|
||||
],
|
||||
"options": ["-loglevel", "level+info"],
|
||||
"reconnect": true,
|
||||
"reconnect_delay_seconds": 5,
|
||||
"autostart": false
|
||||
}
|
||||
```
|
||||
> TikTok entrega la URL del servidor y la clave desde el Creator Center. La URL tiene formato `rtmp://<servidor>/live/<clave>`.
|
||||
|
||||
#### RTMP genérico (cualquier plataforma)
|
||||
```json
|
||||
{
|
||||
"id": "pub-custom",
|
||||
"reference": "pub-custom",
|
||||
"input": [
|
||||
{
|
||||
"id": "in",
|
||||
"address": "{whip-rtsp:name=<stream_key>}",
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"output": [
|
||||
{
|
||||
"id": "out",
|
||||
"address": "<RTMP_URL>",
|
||||
"options": ["-c:v", "copy", "-c:a", "aac", "-b:a", "128k", "-f", "flv"]
|
||||
}
|
||||
],
|
||||
"options": ["-loglevel", "level+info"],
|
||||
"reconnect": true,
|
||||
"reconnect_delay_seconds": 5,
|
||||
"autostart": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8.4 URLs RTMP de plataformas principales
|
||||
|
||||
| Plataforma | URL del servidor | Protocolo |
|
||||
|---|---|---|
|
||||
| YouTube | `rtmp://a.rtmp.youtube.com/live2/<key>` | RTMP |
|
||||
| YouTube (backup) | `rtmp://b.rtmp.youtube.com/live2/<key>` | RTMP |
|
||||
| Twitch | `rtmp://live.twitch.tv/app/<key>` | RTMP |
|
||||
| Twitch (ingest global) | `rtmp://ingest.global-contribute.live-video.net/app/<key>` | RTMP |
|
||||
| Facebook | `rtmps://live-api-s.facebook.com:443/rtmp/<key>` | **RTMPS** |
|
||||
| Instagram | `rtmps://edgetee-upload-<region>.facebook.com:443/rtmp/<key>` | **RTMPS** |
|
||||
| TikTok | URL dinámica desde Creator Center | RTMP |
|
||||
| Kick | `rtmp://ingest.kick.com/app/<key>` | RTMP |
|
||||
| LinkedIn | `rtmps://stream.linkedin.com:443/live/<key>` | **RTMPS** |
|
||||
| X (Twitter) | `rtmps://ingest.pscp.tv:443/x/<key>` | **RTMPS** |
|
||||
|
||||
---
|
||||
|
||||
### 8.5 Panel "Publicaciones" en la UI
|
||||
|
||||
#### Estructura visual
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────── Publicaciones ────┐
|
||||
│ Stream activo: mistream ● En vivo │
|
||||
│ [+ Agregar destino] │
|
||||
│ │
|
||||
│ ┌─────────────┬──────────────────────────┬──────┬─────────┐ │
|
||||
│ │ Plataforma │ URL destino │Estado│ Acciones│ │
|
||||
│ ├─────────────┼──────────────────────────┼──────┼─────────┤ │
|
||||
│ │ ▶ YouTube │ rtmp://…/live2/xxxx │ ✓ OK │ ■ Stop │ │
|
||||
│ │ ■ Twitch │ rtmp://…/app/xxxx │ — — │ ▶ Start │ │
|
||||
│ │ ▶ Facebook │ rtmps://…/rtmp/xxxx │ ✓ OK │ ■ Stop │ │
|
||||
│ └─────────────┴──────────────────────────┴──────┴─────────┘ │
|
||||
│ │
|
||||
│ [▶ Iniciar todos] [■ Detener todos] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Estado de cada destino
|
||||
|
||||
Leer de `GET /api/v3/process/<id>`:
|
||||
- `order: "start"` + `state: "running"` → ✓ En vivo (verde)
|
||||
- `order: "start"` + `state: "failed"` → ✗ Error (rojo) + log disponible
|
||||
- `order: "stop"` → Detenido (gris)
|
||||
- proceso no existe → No configurado
|
||||
|
||||
---
|
||||
|
||||
### 8.6 Formulario "Agregar destino"
|
||||
|
||||
```
|
||||
┌──────────────────────────────── Nuevo destino ────┐
|
||||
│ │
|
||||
│ Plataforma: [YouTube ▼] │
|
||||
│ (YouTube / Twitch / Facebook / │
|
||||
│ TikTok / Kick / Personalizado) │
|
||||
│ │
|
||||
│ Stream Key: [xxxx-xxxx-xxxx-xxxx ] │
|
||||
│ │
|
||||
│ Servidor: [rtmp://a.rtmp.youtube.com/live2/] │
|
||||
│ (autocompletado por plataforma, │
|
||||
│ editable para "Personalizado") │
|
||||
│ │
|
||||
│ Audio: [AAC 128k ▼] │
|
||||
│ (AAC 96k / AAC 128k / AAC 192k) │
|
||||
│ ⚠️ No usar "Copy" — OBS envía Opus │
|
||||
│ │
|
||||
│ Identificador del proceso: [pub-youtube ] │
|
||||
│ │
|
||||
│ [Cancelar] [Guardar] │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Al guardar, la UI llama `POST /api/v3/process` con el payload de la sección 8.3 correspondiente.
|
||||
|
||||
---
|
||||
|
||||
### 8.7 Flujo completo de la UI
|
||||
|
||||
```js
|
||||
// Presets de plataformas
|
||||
const PLATFORM_PRESETS = {
|
||||
youtube: { name: 'YouTube', server: 'rtmp://a.rtmp.youtube.com/live2/', protocol: 'rtmp' },
|
||||
twitch: { name: 'Twitch', server: 'rtmp://live.twitch.tv/app/', protocol: 'rtmp' },
|
||||
facebook: { name: 'Facebook', server: 'rtmps://live-api-s.facebook.com:443/rtmp/', protocol: 'rtmps' },
|
||||
tiktok: { name: 'TikTok', server: '', protocol: 'rtmp' },
|
||||
kick: { name: 'Kick', server: 'rtmp://ingest.kick.com/app/', protocol: 'rtmp' },
|
||||
custom: { name: 'Custom', server: '', protocol: 'rtmp' },
|
||||
}
|
||||
|
||||
// Construir payload de proceso
|
||||
function buildPublishProcess(processId, streamKey, platform, rtmpKey, audioBitrate = '128k') {
|
||||
const preset = PLATFORM_PRESETS[platform]
|
||||
const rtmpUrl = preset.server + rtmpKey
|
||||
|
||||
return {
|
||||
id: processId,
|
||||
reference: processId,
|
||||
input: [{
|
||||
id: 'in',
|
||||
address: `{whip-rtsp:name=${streamKey}}`,
|
||||
options: [],
|
||||
}],
|
||||
output: [{
|
||||
id: 'out',
|
||||
address: rtmpUrl,
|
||||
options: ['-c:v', 'copy', '-c:a', 'aac', '-b:a', audioBitrate, '-f', 'flv'],
|
||||
}],
|
||||
options: ['-loglevel', 'level+info'],
|
||||
reconnect: true,
|
||||
reconnect_delay_seconds: 5,
|
||||
autostart: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Crear proceso
|
||||
async function addDestination(processId, streamKey, platform, rtmpKey, audioBitrate) {
|
||||
const payload = buildPublishProcess(processId, streamKey, platform, rtmpKey, audioBitrate)
|
||||
await api.post('/api/v3/process', payload)
|
||||
}
|
||||
|
||||
// Iniciar proceso
|
||||
async function startDestination(processId) {
|
||||
await api.put(`/api/v3/process/${processId}/command`, { command: 'start' })
|
||||
}
|
||||
|
||||
// Detener proceso
|
||||
async function stopDestination(processId) {
|
||||
await api.put(`/api/v3/process/${processId}/command`, { command: 'stop' })
|
||||
}
|
||||
|
||||
// Iniciar todos los destinos configurados
|
||||
async function startAll(processIds) {
|
||||
await Promise.all(processIds.map(id => startDestination(id)))
|
||||
}
|
||||
|
||||
// Detener todos
|
||||
async function stopAll(processIds) {
|
||||
await Promise.all(processIds.map(id => stopDestination(id)))
|
||||
}
|
||||
|
||||
// Leer estado de un proceso
|
||||
async function getStatus(processId) {
|
||||
const { data } = await api.get(`/api/v3/process/${processId}`)
|
||||
return data // { order: 'start'|'stop', state: 'running'|'failed'|'idle', ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8.8 Control de inicio/detención via API
|
||||
|
||||
Iniciar un proceso (equivale al botón ▶ Start):
|
||||
```
|
||||
PUT /api/v3/process/<id>/command
|
||||
Body: { "command": "start" }
|
||||
```
|
||||
|
||||
Detener un proceso (■ Stop):
|
||||
```
|
||||
PUT /api/v3/process/<id>/command
|
||||
Body: { "command": "stop" }
|
||||
```
|
||||
|
||||
Leer estado actual:
|
||||
```
|
||||
GET /api/v3/process/<id>
|
||||
Response:
|
||||
{
|
||||
"id": "pub-youtube",
|
||||
"order": "start",
|
||||
"state": "running", // "running" | "idle" | "failed" | "starting" | "finishing"
|
||||
"config": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8.9 Mostrar logs de error
|
||||
|
||||
Si `state === "failed"`, mostrar el log del proceso para diagnóstico:
|
||||
```
|
||||
GET /api/v3/process/<id>/log
|
||||
Response: { "log": [ { "ts": 1234567890, "data": "..." }, ... ] }
|
||||
```
|
||||
|
||||
Causas comunes de fallo:
|
||||
| Error en log | Causa | Solución |
|
||||
|---|---|---|
|
||||
| `Connection refused` / `Connection timed out` | Stream key incorrecta o plataforma caída | Verificar key en la plataforma |
|
||||
| `Codec not found: aac` | FFmpeg sin soporte AAC (raro) | Imagen Docker correcta |
|
||||
| `Protocol not found: rtmps` | FFmpeg sin TLS | Solo aplica a Facebook/Instagram — usar imagen con OpenSSL |
|
||||
| `Invalid data found when processing input` | OBS no está transmitiendo | Arrancar OBS primero, luego iniciar procesos |
|
||||
| `Connection reset by peer` | La plataforma cerró el stream (clave duplicada en otra sesión) | Cerrar otras sesiones activas |
|
||||
|
||||
---
|
||||
|
||||
### 8.10 Prerrequisito: relay RTSP habilitado
|
||||
|
||||
El relay RTSP **debe estar activo** para que `{whip-rtsp}` funcione. Verificar antes de mostrar el panel de publicaciones:
|
||||
|
||||
```js
|
||||
async function checkRTSPRelay() {
|
||||
const { data } = await api.get('/api/v3/config')
|
||||
const rtspAddress = data.config.whip?.rtsp_address ?? ''
|
||||
return rtspAddress !== ''
|
||||
}
|
||||
```
|
||||
|
||||
Si el relay no está configurado, mostrar aviso:
|
||||
```
|
||||
⚠️ El relay RTSP no está activo.
|
||||
Para publicar en múltiples plataformas simultáneamente, activá el relay RTSP:
|
||||
Configuración → WHIP Server → Puerto relay RTSP (ej: 8554)
|
||||
```
|
||||
|
||||
Si `rtsp_address` está vacío el usuario puede usar un proceso con `{whip:name=X}` para publicar a **una sola plataforma** (sin relay RTSP); en ese caso los output options son los mismos.
|
||||
|
||||
@ -17,6 +17,7 @@ function init(settings) {
|
||||
case 'hls+diskfs':
|
||||
case 'rtmp':
|
||||
case 'srt':
|
||||
case 'whip-rtsp':
|
||||
break;
|
||||
default:
|
||||
initSettings.source = 'hls+memfs';
|
||||
@ -68,6 +69,12 @@ export default function Control(props) {
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
items.push(
|
||||
<MenuItem key="whip-rtsp" value="whip-rtsp" disabled={!props.sources.includes('whip-rtsp')}>
|
||||
<Trans>WHIP RTSP relay</Trans>
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
|
||||
@ -744,6 +744,8 @@ class Restreamer {
|
||||
host: '',
|
||||
local: 'localhost',
|
||||
token: '',
|
||||
rtspEnabled: false,
|
||||
rtspLocal: 'localhost:8554',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -920,6 +922,17 @@ class Restreamer {
|
||||
config.source.network.whip.local = whip_host.length !== 0 ? whip_host : 'localhost';
|
||||
config.source.network.whip.local += ':' + wp;
|
||||
config.source.network.whip.host = config.hostname + ':' + wp;
|
||||
// RTSP relay
|
||||
const rawRtspAddr = val.config.whip.rtsp_address || '';
|
||||
if (rawRtspAddr) {
|
||||
const [rtsp_host, rtsp_port] = splitHostPort(rawRtspAddr);
|
||||
const rp = rtsp_port || '8554';
|
||||
config.source.network.whip.rtspEnabled = true;
|
||||
config.source.network.whip.rtspLocal = (rtsp_host.length !== 0 ? rtsp_host : 'localhost') + ':' + rp;
|
||||
} else {
|
||||
config.source.network.whip.rtspEnabled = false;
|
||||
config.source.network.whip.rtspLocal = 'localhost:8554';
|
||||
}
|
||||
}
|
||||
|
||||
// Memfs
|
||||
@ -1689,11 +1702,25 @@ class Restreamer {
|
||||
|
||||
for (let i in inputs) {
|
||||
const input = inputs[i];
|
||||
const inputOptions = input.options.map((o) => '' + o);
|
||||
|
||||
// WebRTC/RTP sources (WHIP and WHIP-RTSP) deliver timestamps from the
|
||||
// sender's jitter buffer, which can repeat or go slightly backward.
|
||||
// -use_wallclock_as_timestamps 1 replaces those timestamps with
|
||||
// monotonically increasing wall-clock time, eliminating the
|
||||
// "Non-monotonous DTS" and "Queue input is backward in time" warnings
|
||||
// from the HLS muxer and AAC encoder.
|
||||
const isWhipInput =
|
||||
typeof input.address === 'string' &&
|
||||
(input.address.startsWith('{whip:') || input.address.startsWith('{whip-rtsp:'));
|
||||
if (isWhipInput && !inputOptions.includes('-use_wallclock_as_timestamps')) {
|
||||
inputOptions.unshift('-use_wallclock_as_timestamps', '1');
|
||||
}
|
||||
|
||||
proc.input.push({
|
||||
id: 'input_' + i,
|
||||
address: input.address,
|
||||
options: input.options.map((o) => '' + o),
|
||||
options: inputOptions,
|
||||
});
|
||||
}
|
||||
|
||||
@ -2079,6 +2106,12 @@ class Restreamer {
|
||||
(input.address.startsWith('{whip:') ||
|
||||
(input.address.includes('/whip/') && input.address.includes('/sdp')));
|
||||
|
||||
// WHEP pull inputs: the Core acts as a WHEP subscriber toward an external egress.
|
||||
// They require the same protocol_whitelist as WHIP SDP inputs.
|
||||
const isWhep =
|
||||
typeof input.address === 'string' &&
|
||||
input.address.includes('/whep/');
|
||||
|
||||
const options = input.options.map((o) => '' + o);
|
||||
|
||||
let address = input.address;
|
||||
@ -2101,6 +2134,13 @@ class Restreamer {
|
||||
}
|
||||
}
|
||||
|
||||
if (isWhep && !isWhip) {
|
||||
// WHEP pull URL — same protocol_whitelist requirement as WHIP SDP.
|
||||
if (!options.includes('-protocol_whitelist')) {
|
||||
options.unshift('-protocol_whitelist', 'file,crypto,http,https,rtp,udp,tcp,tls');
|
||||
}
|
||||
}
|
||||
|
||||
config.input.push({
|
||||
id: 'input_' + i,
|
||||
address: address,
|
||||
@ -2108,12 +2148,13 @@ class Restreamer {
|
||||
});
|
||||
}
|
||||
|
||||
// WHIP probes may return 0×0 video resolution when OBS hasn't sent any
|
||||
// frames yet (the SDP is available but no RTP data has arrived). Retry
|
||||
// until we receive either a valid resolution or a fatal error.
|
||||
// Non-WHIP inputs are probed once and returned immediately.
|
||||
// WHIP/WHEP probes may return 0×0 video resolution when the publisher hasn't
|
||||
// sent any frames yet. Retry until a valid resolution arrives or timeout.
|
||||
// Non-WHIP/WHEP inputs are probed once and returned immediately.
|
||||
const isWhipProbe = config.input.some(
|
||||
(inp) => inp.address.includes('/whip/') && inp.address.includes('/sdp'),
|
||||
(inp) =>
|
||||
(inp.address.includes('/whip/') && inp.address.includes('/sdp')) ||
|
||||
inp.address.includes('/whep/'),
|
||||
);
|
||||
const WHIP_MAX_RETRIES = 20; // 20 × 3 s = 60 s maximum wait
|
||||
const WHIP_RETRY_DELAY_MS = 3000;
|
||||
@ -2815,6 +2856,11 @@ class Restreamer {
|
||||
}
|
||||
} else if (control.source.source === 'srt') {
|
||||
address = `{srt,name=${channel.channelid},mode=request}`;
|
||||
} else if (control.source.source === 'whip-rtsp') {
|
||||
// WHIP multi-egress relay: Core expands {whip-rtsp:name=<key>}
|
||||
// → rtsp://127.0.0.1:<rtsp_port>/live/<key> and injects -rtsp_transport tcp.
|
||||
const streamKey = control.source.streamKey || channel.channelid;
|
||||
address = `{whip-rtsp:name=${streamKey}}`;
|
||||
}
|
||||
|
||||
const config = {
|
||||
|
||||
@ -79,20 +79,28 @@ const initSettings = (initialSettings, config) => {
|
||||
};
|
||||
|
||||
// ─── Create FFmpeg inputs ─────────────────────────────────────────────────────
|
||||
// El Core recibe el stream vía WHIP nativo (servidor en :8555).
|
||||
// Input address: {whip:name=<streamKey>}
|
||||
// El Core expande el placeholder a http://localhost:<port>/whip/<key>/sdp
|
||||
// e inyecta automáticamente -protocol_whitelist file,crypto,http,rtp,udp,tcp
|
||||
// El Core hace WHEP pull desde el servidor egress externo (LiveKit).
|
||||
// Input address: <WHIP_SERVER_URL>/whep/rooms/<roomId>
|
||||
//
|
||||
// IMPORTANTE: FFmpeg no tiene demuxer WHEP nativo. El Core de datarhei implementa
|
||||
// WHEP pull internamente cuando detecta la URL del egress como input.
|
||||
// Si el Core expone el placeholder {whep:name=<roomId>}, usar ese en su lugar.
|
||||
// Por ahora se envía la URL directa + protocol_whitelist como fallback HTTP/SDP.
|
||||
const createInputs = (settings, config) => {
|
||||
if (!config) config = {};
|
||||
const streamKey = settings.streamKey || config.channelid || settings.roomId || 'external';
|
||||
|
||||
const effectiveSettings = {
|
||||
...settings,
|
||||
roomId: settings.roomId || config.channelid || 'external',
|
||||
};
|
||||
const whepUrl = buildWhepUrl(effectiveSettings);
|
||||
return [
|
||||
{
|
||||
// Placeholder nativo del Core — NO usar URL directa.
|
||||
// El Core inyecta -protocol_whitelist automáticamente.
|
||||
address: `{whip:name=${streamKey}}`,
|
||||
options: [],
|
||||
// URL WHEP del egress LiveKit.
|
||||
// El Core actúa como cliente WHEP suscriptor.
|
||||
// -protocol_whitelist es necesario para que FFmpeg pueda abrir los
|
||||
// sub-protocolos RTP/UDP anidados en la respuesta SDP HTTP.
|
||||
address: whepUrl,
|
||||
options: ['-protocol_whitelist', 'file,crypto,http,https,rtp,udp,tcp,tls'],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@ -11,6 +11,9 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Radio from '@mui/material/Radio';
|
||||
import RadioGroup from '@mui/material/RadioGroup';
|
||||
import BoxTextarea from '../../../../misc/BoxTextarea';
|
||||
import Textarea from '../../../../misc/Textarea';
|
||||
|
||||
@ -107,14 +110,18 @@ function Source(props) {
|
||||
return () => clearInterval(id);
|
||||
}, [whipInfo, pollWhipStreams, props.restreamer]);
|
||||
|
||||
const rtspAvailable = configWhip.rtspEnabled === true;
|
||||
const [mode, setMode] = React.useState('direct');
|
||||
|
||||
// ── 4. Notify parent ───────────────────────────────────────────────────
|
||||
React.useEffect(() => {
|
||||
if (whipInfo === null) return;
|
||||
const enabled = !!whipInfo;
|
||||
const inputs = [{ address: `{whip:name=${channelid}}`, options: [] }];
|
||||
const addr = mode === 'rtsp' ? `{whip-rtsp:name=${channelid}}` : `{whip:name=${channelid}}`;
|
||||
const inputs = [{ address: addr, options: [] }];
|
||||
props.onChange('network', { mode: 'push', push: { type: 'whip', name: channelid } }, inputs, enabled);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [whipInfo, channelid]);
|
||||
}, [whipInfo, channelid, mode]);
|
||||
|
||||
// ── 5. WHEP preview ────────────────────────────────────────────────────
|
||||
const startPreview = React.useCallback(async () => {
|
||||
@ -218,6 +225,29 @@ function Source(props) {
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" style={{ opacity: 0.7 }}>
|
||||
<Trans>FFmpeg access mode</Trans>
|
||||
</Typography>
|
||||
<RadioGroup row value={mode} onChange={(e) => setMode(e.target.value)}>
|
||||
<FormControlLabel
|
||||
value="direct"
|
||||
control={<Radio size="small" />}
|
||||
label={<Typography variant="body2"><Trans>Direct — single process</Trans></Typography>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="rtsp"
|
||||
control={<Radio size="small" />}
|
||||
disabled={!rtspAvailable}
|
||||
label={<Typography variant="body2"><Trans>RTSP relay — multiple processes</Trans></Typography>}
|
||||
/>
|
||||
</RadioGroup>
|
||||
{mode === 'rtsp' && !rtspAvailable && (
|
||||
<Typography variant="caption" color="error">
|
||||
<Trans>RTSP relay not configured. Enable it in Settings → WHIP Server → Internal RTSP relay port.</Trans>
|
||||
</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography>
|
||||
<Trans>OBS → Settings → Stream → Service: WHIP</Trans>
|
||||
|
||||
@ -16,6 +16,7 @@ import Number from '../../misc/Number';
|
||||
import Paper from '../../misc/Paper';
|
||||
import PaperHeader from '../../misc/PaperHeader';
|
||||
import Services from '../Publication/Services';
|
||||
import WHIPPublications from './WHIPPublications';
|
||||
import { refreshEgressStreamKey } from '../../utils/autoStreamKey';
|
||||
import ytOAuth from '../../utils/ytOAuth';
|
||||
|
||||
@ -239,7 +240,7 @@ export default function Publication(props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<Paper marginBottom="0">
|
||||
<PaperHeader title={<Trans>Publications</Trans>} onAdd={handleServiceAdd} onHelp={handleHelp} />
|
||||
<Grid container spacing={1}>
|
||||
@ -269,7 +270,8 @@ export default function Publication(props) {
|
||||
{egresses}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</React.Fragment>
|
||||
<WHIPPublications channelid={props.channelid} restreamer={props.restreamer} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
522
src/views/Main/WHIPPublications.js
Normal file
522
src/views/Main/WHIPPublications.js
Normal file
@ -0,0 +1,522 @@
|
||||
/**
|
||||
* WHIPPublications — panel §8 del WHIP_UI_INTEGRATION_PROMPT
|
||||
*
|
||||
* Permite re-emitir un stream WHIP a múltiples plataformas (YouTube, Twitch,
|
||||
* Facebook, etc.) simultáneamente usando el relay RTSP interno del Core.
|
||||
*
|
||||
* Cada destino es un proceso FFmpeg independiente con:
|
||||
* input: {whip-rtsp:name=<channelid>}
|
||||
* output: rtmp[s]://<plataforma>/<key> (configurado vía Publication/Add.js)
|
||||
* audio: -c:a aac -b:a 128k ← Opus→AAC obligatorio para RTMP/FLV
|
||||
* video: -c:v copy
|
||||
*
|
||||
* El botón "+" navega a la página estándar Publication/Add, donde el usuario
|
||||
* selecciona la plataforma y configura OAuth2 / stream key / título / descripción
|
||||
* usando los mismos Service components que el panel de Publications normal.
|
||||
* Para que la fuente "WHIP RTSP relay" aparezca en el selector, se añadió
|
||||
* la opción a Source.js y a Add.js localSources (cuando rtspEnabled=true).
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import StreamIcon from '@mui/icons-material/Stream';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
|
||||
import useInterval from '../../hooks/useInterval';
|
||||
import Paper from '../../misc/Paper';
|
||||
import PaperHeader from '../../misc/PaperHeader';
|
||||
import { refreshEgressStreamKey } from '../../utils/autoStreamKey';
|
||||
|
||||
// ─── State badge ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StateBadge({ order, state }) {
|
||||
if (order === 'stop') {
|
||||
return (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<FiberManualRecordIcon style={{ fontSize: 10 }} />}
|
||||
label={<Trans>Stopped</Trans>}
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.5)' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (state === 'running') {
|
||||
return (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<FiberManualRecordIcon style={{ color: '#27ae60', fontSize: 12 }} />}
|
||||
label={<Trans>Live</Trans>}
|
||||
style={{ backgroundColor: 'rgba(39,174,96,0.15)', color: '#27ae60', border: '1px solid #27ae60' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (state === 'failed') {
|
||||
return (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<ErrorOutlineIcon style={{ fontSize: 14 }} />}
|
||||
label={<Trans>Error</Trans>}
|
||||
style={{ backgroundColor: 'rgba(231,76,60,0.15)', color: '#e74c3c', border: '1px solid #e74c3c' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// starting / finishing / idle while order=start
|
||||
return (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<CircularProgress size={10} />}
|
||||
label={<Trans>Starting…</Trans>}
|
||||
style={{ backgroundColor: 'rgba(243,156,18,0.15)', color: '#f39c12' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function WHIPPublications({ channelid, restreamer }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Session config references
|
||||
const configWhip = restreamer?.ConfigActive?.()?.source?.network?.whip || {};
|
||||
|
||||
const [rtspEnabled, setRtspEnabled] = React.useState(null); // null=loading
|
||||
const [whipActive, setWhipActive] = React.useState(false);
|
||||
const [destinations, setDestinations] = React.useState([]);
|
||||
|
||||
// Per-destination log text (shown on error)
|
||||
const [showLog, setShowLog] = React.useState({});
|
||||
const [logs, setLogs] = React.useState({});
|
||||
|
||||
// Metadata cache: id → true (whip-rtsp source) | false (other source)
|
||||
// Avoids re-fetching metadata for already-seen egresses on every poll.
|
||||
const sourceCache = React.useRef(new Map());
|
||||
|
||||
// ── WHEP preview state (same pattern as InternalWHIP.js) ──────────────
|
||||
const [whepUrl, setWhepUrl] = React.useState('');
|
||||
const [bearerToken, setBearerToken] = React.useState('');
|
||||
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);
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
return () => {
|
||||
if (pcRef.current) { pcRef.current.close(); pcRef.current = null; }
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const load = async () => {
|
||||
await checkRtspRelay();
|
||||
await loadWhepUrl();
|
||||
await updateDestinations();
|
||||
await checkWhipActive();
|
||||
};
|
||||
|
||||
// ── Polling ───────────────────────────────────────────────────────────────
|
||||
useInterval(async () => {
|
||||
await checkWhipActive();
|
||||
await updateDestinations();
|
||||
}, 5000);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
const checkRtspRelay = async () => {
|
||||
setRtspEnabled(!!configWhip.rtspEnabled);
|
||||
};
|
||||
|
||||
const loadWhepUrl = async () => {
|
||||
if (!restreamer?.WhipStreamUrl) {
|
||||
// Fallback: build from config
|
||||
if (configWhip.enabled && configWhip.host) {
|
||||
const base = configWhip.base_whep_url || `http://${configWhip.host}/whip/`;
|
||||
setWhepUrl(`${base}${channelid}/whep`);
|
||||
}
|
||||
setBearerToken(configWhip.token || '');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await restreamer.WhipStreamUrl(channelid);
|
||||
if (data?.whep_url) {
|
||||
setWhepUrl(data.whep_url);
|
||||
} else if (configWhip.host) {
|
||||
const base = configWhip.base_whep_url || configWhip.base_publish_url || `http://${configWhip.host}/whip/`;
|
||||
setWhepUrl(`${base}${channelid}/whep`);
|
||||
}
|
||||
setBearerToken(configWhip.token || '');
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
const checkWhipActive = async () => {
|
||||
if (!restreamer?.WhipStreams) return;
|
||||
try {
|
||||
const streams = await restreamer.WhipStreams();
|
||||
setWhipActive(Array.isArray(streams) && streams.some((s) => s.name === channelid));
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
const updateDestinations = async () => {
|
||||
if (!restreamer?.ListIngestEgresses) return;
|
||||
try {
|
||||
const all = await restreamer.ListIngestEgresses(channelid, []);
|
||||
const nonPlayer = all.filter((p) => p.service !== 'player');
|
||||
|
||||
// For each egress not yet seen, check its metadata to know if it's
|
||||
// a whip-rtsp sourced egress. We only fetch metadata once per ID.
|
||||
for (const p of nonPlayer) {
|
||||
if (sourceCache.current.has(p.id)) continue;
|
||||
try {
|
||||
const meta = await restreamer.GetEgressMetadata(channelid, p.id);
|
||||
sourceCache.current.set(p.id, meta?.control?.source?.source === 'whip-rtsp');
|
||||
} catch (_) {
|
||||
sourceCache.current.set(p.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up cache entries for deleted egresses
|
||||
const allIds = new Set(nonPlayer.map((p) => p.id));
|
||||
for (const id of sourceCache.current.keys()) {
|
||||
if (!allIds.has(id)) sourceCache.current.delete(id);
|
||||
}
|
||||
|
||||
setDestinations(nonPlayer.filter((p) => sourceCache.current.get(p.id) === true));
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
const handleAdd = () => {
|
||||
navigate(`/${channelid}/publication/`);
|
||||
};
|
||||
|
||||
const handleEdit = (service, index) => () => {
|
||||
navigate(`/${channelid}/publication/${service}/${index}`);
|
||||
};
|
||||
|
||||
// ── Destination actions ───────────────────────────────────────────────────
|
||||
const handleStart = (id, service) => async () => {
|
||||
// Auto-refresh OAuth2 stream key for YouTube / Facebook before starting
|
||||
try {
|
||||
await refreshEgressStreamKey(restreamer, channelid, id, service);
|
||||
} catch (e) {
|
||||
console.warn('[WHIPPublications] autoStreamKey error (non-fatal):', e.message);
|
||||
}
|
||||
await restreamer.StartEgress(channelid, id);
|
||||
await updateDestinations();
|
||||
};
|
||||
|
||||
const handleStop = (id) => async () => {
|
||||
await restreamer.StopEgress(channelid, id);
|
||||
await updateDestinations();
|
||||
};
|
||||
|
||||
const handleDelete = (id) => async () => {
|
||||
await restreamer.DeleteEgress(channelid, id);
|
||||
sourceCache.current.delete(id);
|
||||
setDestinations((prev) => prev.filter((d) => d.id !== id));
|
||||
};
|
||||
|
||||
const handleToggleLog = async (id, state) => {
|
||||
if (showLog[id]) {
|
||||
setShowLog((prev) => ({ ...prev, [id]: false }));
|
||||
return;
|
||||
}
|
||||
if (state === 'failed') {
|
||||
try {
|
||||
const log = await restreamer.GetEgressLog(channelid, id);
|
||||
const lines = Array.isArray(log) ? log.map((l) => l.data || l).join('\n') : String(log);
|
||||
setLogs((prev) => ({ ...prev, [id]: lines }));
|
||||
} catch (_) {}
|
||||
}
|
||||
setShowLog((prev) => ({ ...prev, [id]: true }));
|
||||
};
|
||||
|
||||
const handleStartAll = async () => {
|
||||
for (const d of destinations) {
|
||||
try { await refreshEgressStreamKey(restreamer, channelid, d.id, d.service); } catch (_) {}
|
||||
await restreamer.StartEgress(channelid, d.id);
|
||||
}
|
||||
await updateDestinations();
|
||||
};
|
||||
|
||||
const handleStopAll = async () => {
|
||||
await Promise.all(destinations.map((d) => restreamer.StopEgress(channelid, d.id)));
|
||||
await updateDestinations();
|
||||
};
|
||||
|
||||
// ── WHEP preview ──────────────────────────────────────────────────────────
|
||||
const startPreview = React.useCallback(async () => {
|
||||
if (!whepUrl) return;
|
||||
setPreviewError('');
|
||||
try {
|
||||
const pc = new RTCPeerConnection({ iceServers: [] });
|
||||
|
||||
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);
|
||||
|
||||
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}`;
|
||||
|
||||
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');
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: await resp.text() });
|
||||
|
||||
pcRef.current = pc;
|
||||
setPreviewActive(true);
|
||||
} catch (err) {
|
||||
setPreviewError(err.message);
|
||||
}
|
||||
}, [whepUrl, bearerToken]);
|
||||
|
||||
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$/, '');
|
||||
try { await fetch(base + locationRef.current, { method: 'DELETE' }); } catch (_) {}
|
||||
locationRef.current = null;
|
||||
}
|
||||
if (videoRef.current) videoRef.current.srcObject = null;
|
||||
setPreviewActive(false);
|
||||
}, [whepUrl]);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<Paper marginBottom="0" style={{ marginTop: 16 }}>
|
||||
<PaperHeader
|
||||
title={
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<StreamIcon fontSize="small" />
|
||||
<Trans>WHIP Publications</Trans>
|
||||
</span>
|
||||
}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
<Grid container spacing={2} style={{ padding: '0 16px 16px' }}>
|
||||
|
||||
{/* ── RTSP relay warning ──────────────────────────────────── */}
|
||||
{rtspEnabled === false && (
|
||||
<Grid item xs={12}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 8,
|
||||
background: 'rgba(243,156,18,0.12)', border: '1px solid rgba(243,156,18,0.4)',
|
||||
borderRadius: 6, padding: '10px 12px',
|
||||
}}>
|
||||
<WarningAmberIcon style={{ color: '#f39c12', marginTop: 1, flexShrink: 0 }} />
|
||||
<Typography variant="body2" style={{ color: 'rgba(255,255,255,0.75)' }}>
|
||||
<Trans>
|
||||
RTSP relay not active. Enable it in Settings → WHIP Server → Internal RTSP relay port (e.g. 8554)
|
||||
to publish to multiple platforms simultaneously.
|
||||
</Trans>
|
||||
</Typography>
|
||||
</div>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* ── WHIP stream status + preview controls ───────────────── */}
|
||||
<Grid item xs={12} style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" style={{ opacity: 0.6 }}>
|
||||
<Trans>OBS:</Trans>
|
||||
</Typography>
|
||||
{whipActive ? (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<FiberManualRecordIcon style={{ color: '#27ae60', fontSize: 12 }} />}
|
||||
label={<Trans>Live — {channelid}</Trans>}
|
||||
style={{ backgroundColor: 'rgba(39,174,96,0.15)', color: '#27ae60', border: '1px solid #27ae60' }}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<FiberManualRecordIcon style={{ fontSize: 12, opacity: 0.4 }} />}
|
||||
label={<Trans>Waiting for OBS…</Trans>}
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.45)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Preview toggle — only useful when OBS is live */}
|
||||
{whepUrl && whipActive && (
|
||||
<Tooltip title={previewActive ? <Trans>Stop preview</Trans> : <Trans>Preview WHIP stream</Trans>}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={previewActive ? stopPreview : startPreview}
|
||||
style={{ marginLeft: 4 }}
|
||||
>
|
||||
{previewActive ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{destinations.length > 0 && (
|
||||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
|
||||
<Button size="small" variant="outlined" startIcon={<PlayArrowIcon />} onClick={handleStartAll}>
|
||||
<Trans>Start all</Trans>
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" color="secondary" startIcon={<StopIcon />} onClick={handleStopAll}>
|
||||
<Trans>Stop all</Trans>
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* ── WHEP inline preview ──────────────────────────────────── */}
|
||||
{previewError && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" color="error">{previewError}</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
{whipActive && (
|
||||
<Grid item xs={12} style={{ display: previewActive ? 'block' : 'none' }}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
controls
|
||||
style={{ width: '100%', maxHeight: 360, borderRadius: 4, background: '#000' }}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* ── Destination list ──────────────────────────────────────── */}
|
||||
{destinations.length === 0 ? (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" style={{ opacity: 0.5, textAlign: 'center', padding: '12px 0' }}>
|
||||
<Trans>No WHIP destinations. Click + to add one via the Publication wizard.</Trans>
|
||||
</Typography>
|
||||
</Grid>
|
||||
) : (
|
||||
destinations.map((d) => (
|
||||
<Grid item xs={12} key={d.id}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: 'rgba(255,255,255,0.04)', borderRadius: 6, padding: '8px 12px',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{/* Platform icon / name */}
|
||||
<Typography variant="body2" style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{d.name || d.service}
|
||||
</Typography>
|
||||
|
||||
{/* State badge */}
|
||||
<StateBadge order={d.order} state={d.state} />
|
||||
|
||||
{/* Actions */}
|
||||
{d.order !== 'start' ? (
|
||||
<Tooltip title={<Trans>Start</Trans>}>
|
||||
<IconButton size="small" onClick={handleStart(d.id, d.service)}>
|
||||
<PlayArrowIcon fontSize="small" style={{ color: '#27ae60' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={<Trans>Stop</Trans>}>
|
||||
<IconButton size="small" onClick={handleStop(d.id)}>
|
||||
<StopIcon fontSize="small" style={{ color: '#e74c3c' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title={<Trans>Edit</Trans>}>
|
||||
<IconButton size="small" onClick={handleEdit(d.service, d.index)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={d.state === 'failed' ? <Trans>Show error</Trans> : <Trans>Delete</Trans>}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={d.state === 'failed'
|
||||
? () => handleToggleLog(d.id, d.state)
|
||||
: handleDelete(d.id)}
|
||||
>
|
||||
{d.state === 'failed'
|
||||
? <ErrorOutlineIcon fontSize="small" style={{ color: '#e74c3c' }} />
|
||||
: <DeleteIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{d.state !== 'failed' && (
|
||||
<Tooltip title={<Trans>Delete</Trans>}>
|
||||
<IconButton size="small" onClick={handleDelete(d.id)}>
|
||||
<DeleteIcon fontSize="small" style={{ opacity: 0.5 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error log */}
|
||||
{showLog[d.id] && (
|
||||
<pre style={{
|
||||
margin: '4px 0 0 0', padding: '8px 12px',
|
||||
background: 'rgba(231,76,60,0.08)', border: '1px solid rgba(231,76,60,0.3)',
|
||||
borderRadius: 4, fontSize: 11, color: '#e74c3c',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: 200, overflow: 'auto',
|
||||
}}>
|
||||
{logs[d.id] || '—'}
|
||||
</pre>
|
||||
)}
|
||||
</Grid>
|
||||
))
|
||||
)}
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
WHIPPublications.defaultProps = {
|
||||
channelid: '',
|
||||
restreamer: null,
|
||||
};
|
||||
@ -117,6 +117,12 @@ export default function Add(props) {
|
||||
localSources.push('srt');
|
||||
}
|
||||
|
||||
// WHIP RTSP relay: available when the Core has rtsp_address configured
|
||||
const coreConfig = props.restreamer.ConfigActive();
|
||||
if (coreConfig?.source?.network?.whip?.rtspEnabled) {
|
||||
localSources.push('whip-rtsp');
|
||||
}
|
||||
|
||||
setLocalSources(localSources);
|
||||
|
||||
setSources(helper.createSourcesFromStreams(ingest.streams));
|
||||
|
||||
@ -405,6 +405,18 @@ const configValues = {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
'whip.rtsp_address': {
|
||||
tab: 'whip',
|
||||
set: (config, value) => {
|
||||
config.whip.rtsp_address = value;
|
||||
},
|
||||
unset: (config) => {
|
||||
delete config.whip.rtsp_address;
|
||||
},
|
||||
validate: (config) => {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
'storage.cors.allow_all': {
|
||||
tab: 'storage',
|
||||
set: (config, value) => {
|
||||
@ -857,11 +869,12 @@ export default function Settings(props) {
|
||||
|
||||
if (config.whip) {
|
||||
config.whip.address = config.whip.address.split(':').join('');
|
||||
config.whip.rtsp_address = (config.whip.rtsp_address || '').split(':').join('');
|
||||
} else {
|
||||
// Core didn't return a whip section (never saved to disk).
|
||||
// Use WhipUrl() to detect live server state: enable flag and port.
|
||||
// Token cannot be recovered this way — it remains blank.
|
||||
config.whip = { enable: false, address: '8555', token: '' };
|
||||
config.whip = { enable: false, address: '8555', token: '', rtsp_address: '' };
|
||||
if (props.restreamer.WhipUrl) {
|
||||
const whipLive = await props.restreamer.WhipUrl();
|
||||
if (whipLive !== null) {
|
||||
@ -995,9 +1008,10 @@ export default function Settings(props) {
|
||||
// Happy path: Core has whip in its config (saved to disk at least once).
|
||||
const rawWhip = data.config.whip;
|
||||
const whip = {
|
||||
enable: rawWhip.enable ?? false,
|
||||
address: (rawWhip.address || ':8555').split(':').join(''),
|
||||
token: rawWhip.token || '',
|
||||
enable: rawWhip.enable ?? false,
|
||||
address: (rawWhip.address || ':8555').split(':').join(''),
|
||||
token: rawWhip.token || '',
|
||||
rtsp_address: (rawWhip.rtsp_address || '').split(':').join(''),
|
||||
};
|
||||
setConfig((prev) => ({ ...prev, data: { ...prev.data, whip } }));
|
||||
} else if (data !== null && props.restreamer.WhipUrl) {
|
||||
@ -1070,6 +1084,7 @@ export default function Settings(props) {
|
||||
|
||||
if (config.whip) {
|
||||
config.whip.address = ':' + config.whip.address;
|
||||
config.whip.rtsp_address = config.whip.rtsp_address ? ':' + config.whip.rtsp_address : '';
|
||||
}
|
||||
|
||||
if (config.tls.auto === true) {
|
||||
@ -2266,6 +2281,21 @@ export default function Settings(props) {
|
||||
<Trans>Optional token for WHIP stream authentication.</Trans>
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
type="number"
|
||||
label={<Trans>Internal RTSP relay port</Trans>}
|
||||
env={env('whip.rtsp_address')}
|
||||
disabled={env('whip.rtsp_address') || !config.whip?.enable}
|
||||
value={config.whip?.rtsp_address || ''}
|
||||
placeholder="8554"
|
||||
onChange={handleChange('whip.rtsp_address')}
|
||||
/>
|
||||
<ErrorBox configvalue="whip.rtsp_address" messages={$tabs.whip.messages} />
|
||||
<Typography variant="caption">
|
||||
<Trans>Enables the internal RTSP relay so multiple FFmpeg processes can consume the same WHIP stream simultaneously. Leave empty to disable.</Trans>
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
<TabPanel value={$tab} index="logging" className="panel">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user