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:
Cesar Mendivil 2026-03-15 17:04:54 -07:00
parent 89446e701f
commit 5c586a2aa3
10 changed files with 1085 additions and 30 deletions

View File

@ -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

View File

@ -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.

View File

@ -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}>

View File

@ -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 = {

View File

@ -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'],
},
];
};

View File

@ -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>

View File

@ -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} />
</>
);
}

View 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 OpusAAC 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,
};

View File

@ -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));

View File

@ -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">