restreamer-ui-v2/public/docs/livekit-rtmp-egress.html
Cesar Mendivil 00e98a19b3 feat: add InternalWHIP component and associated tests
- Implemented the InternalWHIP component for managing WHIP server configurations.
- Added functionality to load live WHIP state from Core and handle OBS URL generation.
- Included polling for active streams and notifying parent components of state changes.
- Created comprehensive tests for the InternalWHIP component covering various scenarios including fallback mechanisms and state changes.

test: add integration tests for WHIP source component

- Developed end-to-end tests for the InternalWHIP component to verify its behavior under different configurations.
- Ensured that the component correctly handles the loading of WHIP state, displays appropriate messages, and emits the correct onChange events.

test: add Settings WHIP configuration tests

- Implemented tests for the WHIP settings tab to validate loading and saving of WHIP configurations.
- Verified that the correct values are sent back to the Core when the user saves changes.
- Ensured that the UI reflects the current state of the WHIP configuration after Core restarts or changes.
2026-03-14 12:27:53 -07:00

1051 lines
53 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>LiveKit Compose → RTMP Egress</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Syne:wght@400;600;800&display=swap" rel="stylesheet"/>
<style>
:root{
--bg:#080c10;--surface:#0e1419;--panel:#141b24;--border:#1e2d3d;
--accent:#00d4aa;--accent2:#ff4d6d;--accent3:#ffd60a;
--text:#cdd6e4;--muted:#4a5568;--live:#ff4d6d;
}
*{box-sizing:border-box;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:'Syne',sans-serif;height:100vh;overflow:hidden;}
body::before{content:'';position:fixed;inset:0;
background-image:linear-gradient(rgba(0,212,170,.03) 1px,transparent 1px),linear-gradient(90deg,rgba(0,212,170,.03) 1px,transparent 1px);
background-size:40px 40px;pointer-events:none;z-index:0;}
header{display:flex;align-items:center;justify-content:space-between;
padding:12px 24px;border-bottom:1px solid var(--border);
background:rgba(8,12,16,.95);backdrop-filter:blur(12px);
position:relative;z-index:200;height:52px;flex-shrink:0;}
.logo{display:flex;align-items:center;gap:8px;font-size:1rem;font-weight:800;color:var(--accent);}
.hbadge{display:flex;align-items:center;gap:6px;padding:4px 12px;border-radius:20px;
background:var(--panel);border:1px solid var(--border);
font-family:'JetBrains Mono',monospace;font-size:.72rem;}
.dot{width:7px;height:7px;border-radius:50%;background:var(--muted);}
.dot.live{background:var(--live);box-shadow:0 0 8px var(--live);animation:pulse 1.5s infinite;}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.app{display:grid;grid-template-columns:250px 1fr 310px;height:calc(100vh - 52px);position:relative;z-index:1;}
/* ── LEFT PANEL ── */
.left-panel{background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;}
.panel-head{padding:12px 14px;border-bottom:1px solid var(--border);flex-shrink:0;}
.panel-title{font-size:.6rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1.5px;}
.panel-body{flex:1;overflow-y:auto;padding:10px;}
.add-row{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:12px;}
.add-btn{display:flex;flex-direction:column;align-items:center;gap:5px;padding:10px 6px;
border:1px dashed var(--border);border-radius:8px;background:none;color:var(--muted);
font-family:'Syne',sans-serif;font-size:.7rem;font-weight:600;cursor:pointer;transition:all .2s;}
.add-btn:hover{border-color:var(--accent);color:var(--accent);background:rgba(0,212,170,.04);}
.add-btn svg{width:18px;height:18px;}
.queue-label{font-size:.58rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:7px;}
.pending-list{display:flex;flex-direction:column;gap:5px;}
.pending-item{display:flex;align-items:center;gap:7px;padding:7px 9px;
background:var(--panel);border:1px solid var(--border);border-radius:7px;transition:all .2s;position:relative;}
.picon{width:26px;height:26px;border-radius:5px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
.picon.cam{background:rgba(0,212,170,.15);color:var(--accent);}
.picon.screen{background:rgba(255,214,10,.15);color:var(--accent3);}
.pending-info{flex:1;min-width:0;}
.pending-name{font-size:.75rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.pending-sub{font-size:.6rem;color:var(--muted);font-family:'JetBrains Mono',monospace;}
.pending-actions{display:flex;gap:3px;flex-shrink:0;}
.icon-btn{background:none;border:1px solid var(--border);color:var(--muted);
width:24px;height:24px;border-radius:4px;cursor:pointer;
display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0;}
.icon-btn:hover{border-color:var(--accent);color:var(--accent);}
.icon-btn.del:hover{border-color:var(--accent2);color:var(--accent2);}
.icon-btn svg{width:11px;height:11px;pointer-events:none;}
.on-canvas-tag{position:absolute;top:3px;right:3px;
background:rgba(0,212,170,.12);border:1px solid rgba(0,212,170,.4);
color:var(--accent);font-size:.52rem;font-weight:700;
padding:1px 4px;border-radius:3px;font-family:'JetBrains Mono',monospace;pointer-events:none;}
/* ── CENTER ── */
.center{display:flex;flex-direction:column;background:#000;overflow:hidden;}
.canvas-toolbar{display:flex;align-items:center;gap:7px;padding:7px 12px;
background:var(--surface);border-bottom:1px solid var(--border);flex-shrink:0;}
.t-label{font-size:.58rem;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:1px;margin-right:auto;}
.t-chip{padding:3px 8px;border-radius:4px;background:var(--panel);border:1px solid var(--border);
font-family:'JetBrains Mono',monospace;font-size:.62rem;color:var(--text);}
.t-chip.live-chip{color:var(--live);border-color:rgba(255,77,109,.4);}
.stage-wrap{flex:1;position:relative;overflow:hidden;background:#060a0d;}
#stage{position:absolute;background:#0c0c0c;overflow:hidden;
box-shadow:0 0 0 1px var(--border),0 0 40px rgba(0,0,0,.8);}
#stage::before{content:'';position:absolute;inset:0;
background-image:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),
linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px);
background-size:20px 20px;pointer-events:none;z-index:0;}
.overlay{position:absolute;border:2px solid transparent;border-radius:3px;
overflow:visible;cursor:move;user-select:none;transition:border-color .12s;z-index:10;}
.overlay-inner{position:absolute;inset:0;overflow:hidden;border-radius:2px;}
.overlay-inner video{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;}
.overlay:hover{border-color:rgba(0,212,170,.45);}
.overlay.selected{border-color:var(--accent)!important;z-index:100;}
.overlay.selected .overlay-inner{box-shadow:inset 0 0 0 1px rgba(0,212,170,.2);}
/* resize handles */
.rh{position:absolute;width:9px;height:9px;background:var(--accent);border:2px solid #0c0c0c;
border-radius:2px;z-index:30;opacity:0;transition:opacity .12s;}
.overlay.selected .rh{opacity:1;}
.rh.nw{top:-5px;left:-5px;cursor:nw-resize;}
.rh.ne{top:-5px;right:-5px;cursor:ne-resize;}
.rh.sw{bottom:-5px;left:-5px;cursor:sw-resize;}
.rh.se{bottom:-5px;right:-5px;cursor:se-resize;}
.rh.n{top:-5px;left:50%;transform:translateX(-50%);cursor:n-resize;}
.rh.s{bottom:-5px;left:50%;transform:translateX(-50%);cursor:s-resize;}
.rh.e{right:-5px;top:50%;transform:translateY(-50%);cursor:e-resize;}
.rh.w{left:-5px;top:50%;transform:translateY(-50%);cursor:w-resize;}
/* overlay toolbar */
.ov-tb{position:absolute;top:-30px;left:-2px;
display:none;align-items:center;gap:2px;
background:var(--panel);border:1px solid var(--border);border-radius:4px;
padding:2px 5px;z-index:50;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,.5);}
.overlay.selected .ov-tb{display:flex;}
.ov-btn{background:none;border:none;color:var(--muted);cursor:pointer;
font-size:.6rem;font-family:'JetBrains Mono',monospace;padding:2px 4px;border-radius:3px;transition:all .12s;}
.ov-btn:hover{color:var(--accent);background:rgba(0,212,170,.1);}
.ov-btn.red:hover{color:var(--accent2);background:rgba(255,77,109,.1);}
.ov-name{font-size:.6rem;color:var(--text);font-family:'JetBrains Mono',monospace;
padding:0 5px 0 2px;border-right:1px solid var(--border);margin-right:2px;max-width:100px;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
/* controls bar */
.controls-bar{display:flex;align-items:center;gap:6px;padding:8px 12px;
background:var(--surface);border-top:1px solid var(--border);flex-shrink:0;}
.ctrl-btn{display:flex;align-items:center;gap:5px;padding:5px 10px;border-radius:5px;
border:1px solid var(--border);background:var(--panel);color:var(--text);
font-family:'Syne',sans-serif;font-size:.72rem;font-weight:600;cursor:pointer;transition:all .2s;}
.ctrl-btn:hover{border-color:var(--accent);color:var(--accent);}
.ctrl-btn svg{width:13px;height:13px;}
.ctrl-sep{width:1px;height:20px;background:var(--border);margin:0 2px;}
/* canvas hint */
.canvas-hint{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;z-index:1;}
.canvas-hint-inner{text-align:center;color:var(--muted);}
.canvas-hint-inner svg{opacity:.15;margin-bottom:10px;}
.canvas-hint-inner p{font-size:.78rem;line-height:1.6;}
/* ── RIGHT PANEL ── */
.right-panel{background:var(--surface);border-left:1px solid var(--border);display:flex;flex-direction:column;overflow-y:auto;}
.section{padding:14px;border-bottom:1px solid var(--border);}
.sec-title{font-size:.58rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1.5px;
margin-bottom:10px;display:flex;align-items:center;gap:6px;}
.sec-title::after{content:'';flex:1;height:1px;background:var(--border);}
.layout-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;}
.lopt{border:1px solid var(--border);border-radius:5px;padding:5px;cursor:pointer;transition:all .2s;
display:flex;flex-direction:column;align-items:center;gap:3px;background:var(--panel);}
.lopt:hover,.lopt.active{border-color:var(--accent);}
.lopt.active{background:rgba(0,212,170,.07);}
.lthumb{width:100%;height:30px;border-radius:2px;background:var(--border);position:relative;overflow:hidden;}
.ln{font-size:.56rem;color:var(--muted);font-weight:600;text-align:center;}
.prop-row{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:6px;}
.prop-group{display:flex;flex-direction:column;gap:2px;margin-bottom:6px;}
.prop-label{font-size:.6rem;color:var(--muted);font-weight:600;}
.prop-input{padding:5px 8px;background:var(--panel);border:1px solid var(--border);
border-radius:4px;color:var(--text);font-family:'JetBrains Mono',monospace;
font-size:.7rem;outline:none;width:100%;transition:border-color .2s;}
.prop-input:focus{border-color:var(--accent);}
.layer-info{background:var(--panel);border:1px solid var(--border);border-radius:5px;padding:8px;margin-bottom:8px;}
.li-row{display:flex;justify-content:space-between;font-size:.65rem;margin-bottom:3px;}
.li-row:last-child{margin-bottom:0;}
.li-k{color:var(--muted);font-family:'JetBrains Mono',monospace;}
.li-v{color:var(--accent);font-family:'JetBrains Mono',monospace;}
.erow{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;font-size:.65rem;font-family:'JetBrains Mono',monospace;}
.erow:last-child{margin-bottom:0;}
.ek{color:var(--muted);}.ev{color:var(--accent);}.ev.warn{color:var(--accent3);}.ev.err{color:var(--accent2);}
.start-btn{width:100%;padding:11px;border-radius:7px;border:none;
background:linear-gradient(135deg,var(--accent),#00a884);
color:#000;font-family:'Syne',sans-serif;font-size:.82rem;font-weight:800;
cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;gap:7px;margin-top:6px;}
.start-btn:hover{transform:translateY(-1px);box-shadow:0 6px 20px rgba(0,212,170,.3);}
.start-btn.streaming{background:linear-gradient(135deg,var(--accent2),#c0392b);}
.code-block{background:#0a0f14;border:1px solid var(--border);border-radius:5px;
padding:10px;font-family:'JetBrains Mono',monospace;font-size:.62rem;
line-height:1.7;overflow-x:auto;color:#a0c4b0;position:relative;max-height:180px;overflow-y:auto;}
.kw{color:#ff79c6;}.str{color:#f1fa8c;}.fn{color:#50fa7b;}.cm{color:var(--muted);font-style:italic;}.prop{color:#8be9fd;}
.copy-code{position:absolute;top:5px;right:5px;background:var(--panel);border:1px solid var(--border);
color:var(--muted);font-size:.58rem;padding:2px 5px;border-radius:3px;cursor:pointer;
font-family:'JetBrains Mono',monospace;transition:all .2s;}
.copy-code:hover{color:var(--accent);border-color:var(--accent);}
.tabs{display:flex;border:1px solid var(--border);border-radius:4px;overflow:hidden;margin-bottom:8px;}
.tab{flex:1;padding:5px;text-align:center;font-size:.62rem;font-weight:600;cursor:pointer;
background:var(--panel);color:var(--muted);border:none;transition:all .2s;}
.tab.active{background:rgba(0,212,170,.1);color:var(--accent);}
::-webkit-scrollbar{width:3px;}
::-webkit-scrollbar-track{background:transparent;}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px;}
.toast{position:fixed;bottom:18px;right:18px;z-index:9999;
background:var(--panel);border:1px solid var(--accent);border-radius:5px;
padding:8px 14px;font-size:.72rem;color:var(--accent);
font-family:'JetBrains Mono',monospace;
opacity:0;transform:translateY(6px);transition:all .22s;pointer-events:none;}
.toast.show{opacity:1;transform:translateY(0);}
</style>
</head>
<body>
<header>
<div class="logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><rect width="24" height="24" rx="5" fill="rgba(0,212,170,.12)"/><path d="M4 12c0-4.4 3.6-8 8-8s8 3.6 8 8" stroke="#00d4aa" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="12" r="2.5" fill="#00d4aa"/></svg>
LiveKit Compose → RTMP
</div>
<div style="display:flex;gap:7px;align-items:center">
<div class="hbadge"><div class="dot" id="statusDot"></div><span id="statusText">IDLE</span></div>
<div class="hbadge" id="timerBadge" style="display:none"><span id="timer">00:00:00</span></div>
</div>
</header>
<div class="app">
<!-- LEFT: Pending Sources -->
<div class="left-panel">
<div class="panel-head"><div class="panel-title">Fuentes — Cola pendiente</div></div>
<div class="panel-body">
<div class="add-row">
<button class="add-btn" onclick="addCamera()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>
+ Cámara
</button>
<button class="add-btn" onclick="addScreen()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
+ Pantalla
</button>
</div>
<div class="queue-label">En espera</div>
<div class="pending-list" id="pendingList">
<div style="text-align:center;color:var(--muted);font-size:.7rem;padding:14px 0;line-height:1.6" id="emptyQueue">
Sin fuentes.<br/>Añade una cámara o pantalla.
</div>
</div>
</div>
</div>
<!-- CENTER: Canvas -->
<div class="center">
<div class="canvas-toolbar">
<span class="t-label">Compose Canvas</span>
<span class="t-chip" id="resChip">1920×1080</span>
<span class="t-chip" id="fpsChip">30 fps</span>
<span class="t-chip live-chip" id="liveChip" style="display:none">● LIVE</span>
</div>
<div class="stage-wrap" id="stageWrap">
<div id="stage">
<div class="canvas-hint" id="canvasHint">
<div class="canvas-hint-inner">
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width=".8"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
<p>Pulsa <strong style="color:var(--accent)">+</strong> en una fuente<br/>para añadirla al canvas</p>
<p style="font-size:.65rem;margin-top:6px;opacity:.6">Luego arrástrala y redimensiona libremente</p>
</div>
</div>
</div>
</div>
<div class="controls-bar">
<button class="ctrl-btn" onclick="deselect()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3l18 18"/></svg>
Deselect
</button>
<div class="ctrl-sep"></div>
<button class="ctrl-btn" onclick="bringFront()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 11 12 6 7 11"/><polyline points="17 18 12 13 7 18"/></svg>
Al frente
</button>
<button class="ctrl-btn" onclick="sendBack()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 6 12 11 7 6"/><polyline points="17 13 12 18 7 13"/></svg>
Atrás
</button>
<div class="ctrl-sep"></div>
<button class="ctrl-btn" onclick="removeSelected()" style="color:var(--accent2);border-color:rgba(255,77,109,.4)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
Quitar capa
</button>
<button class="ctrl-btn" onclick="clearCanvas()" style="margin-left:auto;color:var(--muted)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg>
Limpiar
</button>
</div>
</div>
<!-- RIGHT: Config -->
<div class="right-panel">
<!-- Selected layer -->
<div class="section">
<div class="sec-title">Capa seleccionada</div>
<div id="noSelMsg" style="color:var(--muted);font-size:.72rem;text-align:center;padding:6px 0">
Selecciona un elemento en el canvas
</div>
<div id="selProps" style="display:none">
<div class="layer-info">
<div class="li-row"><span class="li-k">nombre</span><span class="li-v" id="liName"></span></div>
<div class="li-row"><span class="li-k">pos</span><span class="li-v" id="liPos"></span></div>
<div class="li-row"><span class="li-k">tamaño</span><span class="li-v" id="liSize"></span></div>
<div class="li-row"><span class="li-k">z-index</span><span class="li-v" id="liZ"></span></div>
</div>
<div class="prop-row">
<div class="prop-group"><div class="prop-label">X (px)</div><input class="prop-input" id="propX" type="number" oninput="applyProp()"/></div>
<div class="prop-group"><div class="prop-label">Y (px)</div><input class="prop-input" id="propY" type="number" oninput="applyProp()"/></div>
</div>
<div class="prop-row">
<div class="prop-group"><div class="prop-label">W (px)</div><input class="prop-input" id="propW" type="number" oninput="applyProp()"/></div>
<div class="prop-group"><div class="prop-label">H (px)</div><input class="prop-input" id="propH" type="number" oninput="applyProp()"/></div>
</div>
<div class="prop-group">
<div class="prop-label">Opacidad: <span id="opVal">100</span>%</div>
<input type="range" min="10" max="100" value="100" id="propOpacity"
oninput="applyOpacity(this.value);document.getElementById('opVal').textContent=this.value"
style="width:100%;accent-color:var(--accent)"/>
</div>
</div>
</div>
<!-- Layouts -->
<div class="section">
<div class="sec-title">Layouts predefinidos</div>
<div class="layout-grid">
<div class="lopt" onclick="applyLayout('full',this)">
<div class="lthumb" style="padding:3px"><div style="width:100%;height:100%;background:rgba(0,212,170,.35);border-radius:1px"></div></div>
<div class="ln">Full</div>
</div>
<div class="lopt" onclick="applyLayout('side',this)">
<div class="lthumb" style="display:flex;gap:2px;padding:3px">
<div style="flex:1;background:rgba(0,212,170,.3);border-radius:1px"></div>
<div style="flex:1;background:rgba(0,212,170,.18);border-radius:1px"></div>
</div>
<div class="ln">Side</div>
</div>
<div class="lopt" onclick="applyLayout('pip-br',this)">
<div class="lthumb" style="position:relative;background:rgba(0,212,170,.18);margin:3px">
<div style="position:absolute;bottom:2px;right:2px;width:36%;height:52%;background:rgba(0,212,170,.6);border-radius:1px;border:1px solid #00d4aa"></div>
</div>
<div class="ln">PiP ↘</div>
</div>
<div class="lopt" onclick="applyLayout('pip-bl',this)">
<div class="lthumb" style="position:relative;background:rgba(0,212,170,.18);margin:3px">
<div style="position:absolute;bottom:2px;left:2px;width:36%;height:52%;background:rgba(0,212,170,.6);border-radius:1px;border:1px solid #00d4aa"></div>
</div>
<div class="ln">PiP ↙</div>
</div>
<div class="lopt" onclick="applyLayout('tri',this)">
<div class="lthumb" style="display:flex;gap:2px;padding:3px">
<div style="flex:2;background:rgba(0,212,170,.28);border-radius:1px"></div>
<div style="flex:1;display:flex;flex-direction:column;gap:2px">
<div style="flex:1;background:rgba(0,212,170,.18);border-radius:1px"></div>
<div style="flex:1;background:rgba(255,214,10,.18);border-radius:1px"></div>
</div>
</div>
<div class="ln">1+2</div>
</div>
<div class="lopt" onclick="applyLayout('quad',this)">
<div class="lthumb" style="display:grid;grid-template-columns:1fr 1fr;gap:2px;padding:3px">
<div style="background:rgba(0,212,170,.28);border-radius:1px"></div>
<div style="background:rgba(0,212,170,.18);border-radius:1px"></div>
<div style="background:rgba(255,214,10,.14);border-radius:1px"></div>
<div style="background:rgba(255,77,109,.14);border-radius:1px"></div>
</div>
<div class="ln">2×2</div>
</div>
</div>
<div style="font-size:.6rem;color:var(--muted);margin-top:6px;line-height:1.5">
Reorganiza las capas activas sin solapamiento. Las fuentes sin slot mantienen su posición.
</div>
</div>
<!-- LiveKit -->
<div class="section">
<div class="sec-title">LiveKit Server</div>
<div class="prop-group"><div class="prop-label">URL</div><input class="prop-input" id="lvkUrl" value="wss://your-livekit.livekit.cloud" oninput="updateCode()"/></div>
<div class="prop-row">
<div class="prop-group"><div class="prop-label">API Key</div><input class="prop-input" id="lvkKey" value="APIxxxxxxx" oninput="updateCode()"/></div>
<div class="prop-group"><div class="prop-label">Room</div><input class="prop-input" id="lvkRoom" value="broadcast-1" oninput="updateCode()"/></div>
</div>
</div>
<!-- RTMP -->
<div class="section">
<div class="sec-title">Destino RTMP</div>
<div class="prop-group"><div class="prop-label">URL</div><input class="prop-input" id="rtmpUrl" value="rtmp://live.twitch.tv/app/" oninput="updateCode()"/></div>
<div class="prop-group"><div class="prop-label">Stream Key</div><input class="prop-input" id="rtmpKey" value="live_xxxxxxxxxx" oninput="updateCode()"/></div>
</div>
<!-- Encode -->
<div class="section">
<div class="sec-title">Encode</div>
<div class="prop-row">
<div class="prop-group"><div class="prop-label">Ancho</div><input class="prop-input" id="cfgW" type="number" value="1920" oninput="updateCode();updateResChip()"/></div>
<div class="prop-group"><div class="prop-label">Alto</div><input class="prop-input" id="cfgH" type="number" value="1080" oninput="updateCode();updateResChip()"/></div>
</div>
<div class="prop-row">
<div class="prop-group"><div class="prop-label">FPS</div>
<select class="prop-input" id="cfgFps" onchange="updateCode();document.getElementById('fpsChip').textContent=this.value+' fps'">
<option>24</option><option selected>30</option><option>60</option>
</select>
</div>
<div class="prop-group"><div class="prop-label">Bitrate kbps</div><input class="prop-input" id="cfgBitrate" type="number" value="4500" oninput="updateCode()"/></div>
</div>
</div>
<!-- Code -->
<div class="section">
<div class="sec-title">API Payload</div>
<div class="tabs">
<button class="tab active" onclick="switchTab('curl',this)">cURL</button>
<button class="tab" onclick="switchTab('js',this)">JS</button>
<button class="tab" onclick="switchTab('go',this)">Go</button>
</div>
<div class="code-block"><button class="copy-code" onclick="copyCode()">copy</button><div id="codeContent"></div></div>
</div>
<!-- Egress + start -->
<div class="section">
<div class="sec-title">Egress</div>
<div style="background:var(--panel);border:1px solid var(--border);border-radius:5px;padding:9px;margin-bottom:8px">
<div class="erow"><span class="ek">egress_id</span><span class="ev" id="eId"></span></div>
<div class="erow"><span class="ek">status</span><span class="ev warn" id="eState">IDLE</span></div>
<div class="erow"><span class="ek">started_at</span><span class="ev" id="eStart"></span></div>
<div class="erow"><span class="ek">capas activas</span><span class="ev" id="eOverlays">0</span></div>
</div>
<button class="start-btn" id="startBtn" onclick="toggleStream()">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3.5" fill="#000"/></svg>
Iniciar Streaming
</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// ════════════════════════════════════════
// STATE
// ════════════════════════════════════════
const pending = [];
const layers = [];
let selectedId = null;
let streaming = false;
let timerInt = null, startTime = null;
let currentTab = 'curl';
let zCtr = 10;
const egressId = 'eg_' + Math.random().toString(36).slice(2,10);
const CW = 1920, CH = 1080;
// ════════════════════════════════════════
// STAGE SCALE
// ════════════════════════════════════════
function getScale() {
const w = document.getElementById('stageWrap');
return Math.min((w.clientWidth-32)/CW, (w.clientHeight-32)/CH);
}
function resizeStage() {
const stage = document.getElementById('stage');
const wrap = document.getElementById('stageWrap');
const sc = getScale();
const sw = CW*sc, sh = CH*sc;
stage.style.width = sw+'px';
stage.style.height = sh+'px';
stage.style.left = ((wrap.clientWidth-sw)/2)+'px';
stage.style.top = ((wrap.clientHeight-sh)/2)+'px';
layers.forEach(l => positionEl(l));
}
window.addEventListener('resize', resizeStage);
function positionEl(l) {
const sc = getScale();
l.el.style.left = (l.x*sc)+'px';
l.el.style.top = (l.y*sc)+'px';
l.el.style.width = (l.w*sc)+'px';
l.el.style.height = (l.h*sc)+'px';
}
// ════════════════════════════════════════
// CAPTURE
// ════════════════════════════════════════
async function addCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({video:true,audio:true});
const devs = await navigator.mediaDevices.enumerateDevices();
const cam = devs.find(d=>d.kind==='videoinput');
const id = 'cam_'+Date.now();
pending.push({id, type:'cam', stream, label:(cam?.label||'').split('(')[0].trim()||'Cámara '+(pending.filter(p=>p.type==='cam').length+1)});
renderPending();
toast('✓ Cámara lista en cola');
} catch(e){ toast('⚠ '+e.message,true); }
}
async function addScreen() {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({video:true,audio:true});
const id = 'screen_'+Date.now();
const label = 'Pantalla '+(pending.filter(p=>p.type==='screen').length+1);
pending.push({id, type:'screen', stream, label});
stream.getVideoTracks()[0].onended = () => {
const pi = pending.findIndex(p=>p.id===id);
if(pi>=0){pending.splice(pi,1);renderPending();}
if(layers.some(l=>l.id===id)) removeLayer(id);
};
renderPending();
toast('✓ Pantalla lista en cola');
} catch(e){ if(e.name!=='NotAllowedError') toast('⚠ '+e.message,true); }
}
// ════════════════════════════════════════
// PENDING RENDER
// ════════════════════════════════════════
function renderPending() {
const list = document.getElementById('pendingList');
document.getElementById('emptyQueue').style.display = pending.length?'none':'block';
list.querySelectorAll('.pending-item').forEach(e=>e.remove());
pending.forEach(src => {
const onCanvas = layers.some(l=>l.id===src.id);
const div = document.createElement('div');
div.className = 'pending-item';
const camSvg = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>';
const scrSvg = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>';
div.innerHTML = `
<div class="picon ${src.type}">${src.type==='cam'?camSvg:scrSvg}</div>
<div class="pending-info">
<div class="pending-name">${src.label}</div>
<div class="pending-sub">${onCanvas?'● en canvas':'○ pendiente'}</div>
</div>
<div class="pending-actions">
${!onCanvas
? `<button class="icon-btn" title="Añadir al canvas" onclick="addToCanvas('${src.id}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>`
: `<button class="icon-btn" title="Seleccionar" onclick="selectById('${src.id}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/></svg>
</button>`}
<button class="icon-btn del" title="Eliminar" onclick="deletePending('${src.id}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
${onCanvas?'<div class="on-canvas-tag">canvas</div>':''}
`;
list.appendChild(div);
});
}
function deletePending(id) {
const idx = pending.findIndex(p=>p.id===id);
if(idx<0) return;
pending[idx].stream.getTracks().forEach(t=>t.stop());
pending.splice(idx,1);
if(layers.some(l=>l.id===id)) removeLayer(id);
renderPending();
}
// ════════════════════════════════════════
// ADD TO CANVAS
// ════════════════════════════════════════
function addToCanvas(id) {
const src = pending.find(p=>p.id===id);
if(!src || layers.some(l=>l.id===id)) return;
// Default: 50% width, 16:9. Cascade offset.
const dw = Math.round(CW * .5);
const dh = Math.round(dw * 9/16);
const off = layers.length * 48;
const x = Math.min(off, CW-dw-20);
const y = Math.min(off, CH-dh-20);
const layer = {id:src.id, type:src.type, stream:src.stream, label:src.label, x, y, w:dw, h:dh, z:++zCtr, el:null};
layer.el = buildOverlayEl(layer);
document.getElementById('stage').appendChild(layer.el);
layers.push(layer);
positionEl(layer);
selectLayer(id);
renderPending();
updateHint();
updateCode();
toast('✓ "'+src.label+'" añadido al canvas');
}
// ════════════════════════════════════════
// BUILD OVERLAY ELEMENT
// ════════════════════════════════════════
function buildOverlayEl(layer) {
const wrap = document.createElement('div');
wrap.className = 'overlay';
wrap.dataset.id = layer.id;
wrap.style.zIndex = layer.z;
// inner clip container
const inner = document.createElement('div');
inner.className = 'overlay-inner';
const vid = document.createElement('video');
vid.autoplay = true; vid.muted = true; vid.playsInline = true;
vid.srcObject = layer.stream;
inner.appendChild(vid);
wrap.appendChild(inner);
// toolbar
const tb = document.createElement('div');
tb.className = 'ov-tb';
tb.innerHTML = `<span class="ov-name">${layer.label}</span>
<button class="ov-btn" onclick="bringFront()" title="Al frente">↑</button>
<button class="ov-btn" onclick="sendBack()" title="Atrás">↓</button>
<button class="ov-btn red" onclick="removeSelected()" title="Quitar">✕</button>`;
wrap.appendChild(tb);
// resize handles
['nw','n','ne','e','se','s','sw','w'].forEach(d=>{
const h = document.createElement('div');
h.className='rh '+d; h.dataset.dir=d;
wrap.appendChild(h);
});
makeDraggable(wrap, layer);
makeResizable(wrap, layer);
wrap.addEventListener('mousedown', e=>{
if(e.target.classList.contains('rh')) return;
if(e.target.closest('.ov-tb')) return;
selectLayer(layer.id);
});
return wrap;
}
// ════════════════════════════════════════
// DRAG
// ════════════════════════════════════════
function makeDraggable(el, layer) {
let ox,oy,lx,ly,down=false;
el.addEventListener('mousedown', e=>{
if(e.target.classList.contains('rh')||e.target.closest('.ov-tb')) return;
e.preventDefault(); down=true;
const sc=getScale();
ox=e.clientX; oy=e.clientY; lx=layer.x; ly=layer.y;
const mv=e2=>{
if(!down) return;
layer.x=Math.max(0,Math.min(CW-layer.w, lx+(e2.clientX-ox)/sc));
layer.y=Math.max(0,Math.min(CH-layer.h, ly+(e2.clientY-oy)/sc));
positionEl(layer); updateSelProps();
};
const up=()=>{down=false;document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);};
document.addEventListener('mousemove',mv);
document.addEventListener('mouseup',up);
});
}
// ════════════════════════════════════════
// RESIZE
// ════════════════════════════════════════
function makeResizable(el, layer) {
el.querySelectorAll('.rh').forEach(h=>{
h.addEventListener('mousedown', e=>{
e.preventDefault(); e.stopPropagation();
const dir=h.dataset.dir, sc=getScale();
const sx=e.clientX, sy=e.clientY;
const ox=layer.x,oy=layer.y,ow=layer.w,oh=layer.h;
const MIN=60;
const mv=e2=>{
const dx=(e2.clientX-sx)/sc, dy=(e2.clientY-sy)/sc;
let nx=ox,ny=oy,nw=ow,nh=oh;
if(dir.includes('e')) nw=Math.max(MIN,ow+dx);
if(dir.includes('s')) nh=Math.max(MIN,oh+dy);
if(dir.includes('w')){nw=Math.max(MIN,ow-dx);nx=ox+(ow-nw);}
if(dir.includes('n')){nh=Math.max(MIN,oh-dy);ny=oy+(oh-nh);}
layer.x=nx;layer.y=ny;layer.w=nw;layer.h=nh;
positionEl(layer); updateSelProps();
};
const up=()=>{document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);};
document.addEventListener('mousemove',mv);
document.addEventListener('mouseup',up);
});
});
}
// ════════════════════════════════════════
// SELECTION
// ════════════════════════════════════════
function selectLayer(id) {
selectedId=id;
layers.forEach(l=>l.el.classList.toggle('selected',l.id===id));
updateSelProps();
}
function selectById(id){ selectLayer(id); toast('Elemento seleccionado'); }
function deselect(){ selectedId=null; layers.forEach(l=>l.el.classList.remove('selected')); updateSelProps(); }
function updateSelProps() {
const l=layers.find(x=>x.id===selectedId);
document.getElementById('noSelMsg').style.display = l?'none':'block';
document.getElementById('selProps').style.display = l?'block':'none';
if(!l) return;
document.getElementById('liName').textContent = l.label;
document.getElementById('liPos').textContent = Math.round(l.x)+'px, '+Math.round(l.y)+'px';
document.getElementById('liSize').textContent = Math.round(l.w)+'×'+Math.round(l.h);
document.getElementById('liZ').textContent = l.z;
document.getElementById('propX').value = Math.round(l.x);
document.getElementById('propY').value = Math.round(l.y);
document.getElementById('propW').value = Math.round(l.w);
document.getElementById('propH').value = Math.round(l.h);
document.getElementById('eOverlays').textContent = layers.length;
}
function applyProp() {
const l=layers.find(x=>x.id===selectedId); if(!l) return;
l.x=+document.getElementById('propX').value||0;
l.y=+document.getElementById('propY').value||0;
l.w=+document.getElementById('propW').value||320;
l.h=+document.getElementById('propH').value||180;
positionEl(l); updateSelProps();
}
function applyOpacity(v) {
const l=layers.find(x=>x.id===selectedId);
if(l) l.el.style.opacity=v/100;
}
// ════════════════════════════════════════
// Z-ORDER
// ════════════════════════════════════════
function bringFront() {
const l=layers.find(x=>x.id===selectedId); if(!l) return;
l.z=++zCtr; l.el.style.zIndex=l.z; updateSelProps();
}
function sendBack() {
const l=layers.find(x=>x.id===selectedId); if(!l) return;
l.z=Math.max(1,l.z-2); l.el.style.zIndex=l.z; updateSelProps();
}
// ════════════════════════════════════════
// REMOVE
// ════════════════════════════════════════
function removeLayer(id) {
const idx=layers.findIndex(l=>l.id===id); if(idx<0) return;
layers[idx].el.remove(); layers.splice(idx,1);
if(selectedId===id) deselect();
renderPending(); updateHint(); updateCode();
}
function removeSelected(){ if(selectedId) removeLayer(selectedId); }
function clearCanvas(){ [...layers].forEach(l=>removeLayer(l.id)); }
// ════════════════════════════════════════
// LAYOUT PRESETS — no overlap
// ════════════════════════════════════════
function applyLayout(name, el) {
document.querySelectorAll('.lopt').forEach(o=>o.classList.remove('active'));
el.classList.add('active');
if(!layers.length){ toast('Sin capas en el canvas',true); return; }
const n=layers.length, W=CW, H=CH, G=4;
let slots=[];
if(name==='full') {
layers.forEach(()=> slots.push({x:0,y:0,w:W,h:H}));
} else if(name==='side') {
const tw=Math.floor((W-G*(n-1))/n);
layers.forEach((_,i)=> slots.push({x:i*(tw+G),y:0,w:tw,h:H}));
} else if(name==='pip-br') {
slots.push({x:0,y:0,w:W,h:H});
const pw=Math.round(W*.27), ph=Math.round(pw*9/16);
for(let i=1;i<n;i++) slots.push({x:W-pw-28,y:H-ph-28,w:pw,h:ph});
} else if(name==='pip-bl') {
slots.push({x:0,y:0,w:W,h:H});
const pw=Math.round(W*.27), ph=Math.round(pw*9/16);
for(let i=1;i<n;i++) slots.push({x:28,y:H-ph-28,w:pw,h:ph});
} else if(name==='tri') {
const mw=Math.floor(W*2/3);
slots.push({x:0,y:0,w:mw,h:H});
const rest=n-1;
if(rest>0){
const rh=Math.floor((H-G*(rest-1))/rest);
for(let i=0;i<rest;i++) slots.push({x:mw+G,y:i*(rh+G),w:W-mw-G,h:rh});
}
} else if(name==='quad') {
const cols=Math.ceil(Math.sqrt(n)), rows=Math.ceil(n/cols);
const tw=Math.floor((W-G*(cols-1))/cols), th=Math.floor((H-G*(rows-1))/rows);
layers.forEach((_,i)=>{
const c=i%cols, r=Math.floor(i/cols);
slots.push({x:c*(tw+G),y:r*(th+G),w:tw,h:th});
});
}
layers.forEach((l,i)=>{
const s=slots[Math.min(i,slots.length-1)];
l.x=s.x; l.y=s.y; l.w=s.w; l.h=s.h;
l.z=10+i; l.el.style.zIndex=l.z;
positionEl(l);
});
deselect(); updateCode();
toast('Layout "'+name+'" aplicado');
}
// ════════════════════════════════════════
// STREAMING
// ════════════════════════════════════════
function toggleStream() {
streaming=!streaming;
const btn=document.getElementById('startBtn');
const dot=document.getElementById('statusDot');
if(streaming){
btn.className='start-btn streaming';
btn.innerHTML='<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg> Detener';
dot.classList.add('live');
document.getElementById('statusText').textContent='LIVE';
document.getElementById('liveChip').style.display='inline-block';
document.getElementById('timerBadge').style.display='flex';
startTime=Date.now();
timerInt=setInterval(()=>{
const e=Date.now()-startTime;
const h=String(Math.floor(e/3600000)).padStart(2,'0');
const m=String(Math.floor((e%3600000)/60000)).padStart(2,'0');
const s=String(Math.floor((e%60000)/1000)).padStart(2,'0');
document.getElementById('timer').textContent=h+':'+m+':'+s;
},1000);
document.getElementById('eId').textContent=egressId;
document.getElementById('eState').textContent='ACTIVE';
document.getElementById('eState').className='ev';
document.getElementById('eStart').textContent=new Date().toISOString().slice(0,19).replace('T',' ');
toast('🔴 Streaming iniciado');
// start backend relay + WS streaming
startRelay().catch(err => { console.warn('startRelay failed', err); toast('Error iniciando relay: '+err.message, true); });
} else {
btn.className='start-btn';
btn.innerHTML='<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3.5" fill="#000"/></svg> Iniciar Streaming';
dot.classList.remove('live');
document.getElementById('statusText').textContent='IDLE';
document.getElementById('liveChip').style.display='none';
document.getElementById('timerBadge').style.display='none';
clearInterval(timerInt);
document.getElementById('eState').textContent='ENDED';
document.getElementById('eState').className='ev warn';
// stop backend relay + WS streaming
stopRelaySession(egressId);
toast('⏹ Streaming detenido');
}
updateCode();
}
// Lanza el relay en el backend. POST /livekit/relay/start con el payload generado
async function startRelay() {
// Note: UI state is handled by toggleStream(); this function only starts the backend relay + ws
const payload = buildPayload();
// Construir URL RTMP destino hacia Core usando el egressId generado
const coreHost = (window.location.hostname && window.location.hostname !== '') ? window.location.hostname : 'restreamer.nextream.sytes.net';
const rtmpPort = 1935;
const rtmpApp = 'live';
const rtmpUrl = `rtmp://${coreHost}:${rtmpPort}/${rtmpApp}/${egressId}.stream`;
payload.stream_outputs = [{ urls: [rtmpUrl] }];
try {
const res = await fetch('/livekit/relay/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await res.json().catch(() => ({}));
if (res.ok) {
toast('Relay solicitado: ' + (json.message || 'OK'));
// Después de registrar el relay en el backend, abrir WS y enviar MediaRecorder
openRelaySession(json.streamName || egressId).catch(err => {
console.warn('openRelaySession error:', err);
toast('No se pudo abrir sesión relay: ' + err.message, true);
});
} else {
toast('Error iniciando relay: ' + (json.message || JSON.stringify(json)), true);
}
} catch (err) {
toast('Error de red: ' + err.message, true);
}
}
// ---------- WebSocket + MediaRecorder relay (client) ----------
let _relayWs = null;
let _mediaRecorder = null;
let _canvasStream = null;
let _captureAnimation = null;
let _offscreenCanvas = null;
async function openRelaySession(roomName) {
if (!roomName) roomName = egressId;
// Open WebSocket to server relay
const wsUrl = `${location.origin.replace(/^http/, 'ws')}/webrtc-relay/${encodeURIComponent(roomName)}`;
_relayWs = new WebSocket(wsUrl);
_relayWs.binaryType = 'arraybuffer';
_relayWs.addEventListener('open', () => {
console.log('[relay] ws open');
// send config
const cfg = { type: 'config', room: roomName, videoBitrate: (+document.getElementById('cfgBitrate').value || 2500) * 1000, audioBitrate: 128000, mimeType: 'video/webm;codecs=vp8,opus' };
_relayWs.send(JSON.stringify(cfg));
});
_relayWs.addEventListener('message', (ev) => {
try { const j = JSON.parse(ev.data); console.log('[relay] msg', j); } catch(e) { }
});
_relayWs.addEventListener('close', () => { console.log('[relay] ws closed'); });
_relayWs.addEventListener('error', (err) => { console.error('[relay] ws error', err); });
// Start canvas compositor capture
startCanvasCapture();
// Start MediaRecorder on canvas stream
if (!_canvasStream) throw new Error('canvas stream not available');
const options = { mimeType: 'video/webm;codecs=vp8,opus' };
_mediaRecorder = new MediaRecorder(_canvasStream, options);
_mediaRecorder.ondataavailable = async (e) => {
if (!_relayWs || _relayWs.readyState !== WebSocket.OPEN) return;
if (e.data && e.data.size > 0) {
try {
const buf = await e.data.arrayBuffer();
_relayWs.send(buf);
} catch (err) {
console.warn('relay send error', err);
}
}
};
_mediaRecorder.onstop = () => { console.log('[relay] recorder stopped'); };
_mediaRecorder.start(1000);
console.log('[relay] MediaRecorder started');
}
function stopRelaySession(roomName) {
// stop MediaRecorder
try { if (_mediaRecorder && _mediaRecorder.state !== 'inactive') _mediaRecorder.stop(); } catch(_) {}
// stop canvas capture
try { stopCanvasCapture(); } catch(_) {}
// close ws
try { if (_relayWs) _relayWs.close(); } catch(_) {}
_relayWs = null; _mediaRecorder = null; _canvasStream = null;
// notify backend to unregister
if (!roomName) roomName = egressId;
fetch('/livekit/relay/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ roomName }) }).catch(()=>{});
}
function startCanvasCapture() {
// create offscreen canvas sized to logical composition (CW x CH)
if (_offscreenCanvas) return;
_offscreenCanvas = document.createElement('canvas');
_offscreenCanvas.width = CW; _offscreenCanvas.height = CH;
const ctx = _offscreenCanvas.getContext('2d');
// create stream
_canvasStream = _offscreenCanvas.captureStream(+document.getElementById('cfgFps').value || 30);
function draw() {
ctx.fillStyle = '#000'; ctx.fillRect(0,0,CW,CH);
// draw each layer's video element at its logical position
layers.forEach(l => {
try {
const v = l.el && l.el.querySelector && l.el.querySelector('video');
if (v && v.readyState >= 2) {
ctx.drawImage(v, l.x, l.y, l.w, l.h);
}
} catch (_) {}
});
_captureAnimation = requestAnimationFrame(draw);
}
_captureAnimation = requestAnimationFrame(draw);
}
function stopCanvasCapture() {
if (_captureAnimation) { cancelAnimationFrame(_captureAnimation); _captureAnimation = null; }
if (_offscreenCanvas) { _offscreenCanvas.width = 1; _offscreenCanvas.height = 1; _offscreenCanvas = null; }
if (_canvasStream) {
_canvasStream.getTracks().forEach(t => t.stop());
_canvasStream = null;
}
}
// ════════════════════════════════════════
// CODE GEN
// ════════════════════════════════════════
function buildPayload() {
return {
room_name: document.getElementById('lvkRoom').value,
layout:'custom',
options:{
width:+document.getElementById('cfgW').value,
height:+document.getElementById('cfgH').value,
framerate:+document.getElementById('cfgFps').value,
video_bitrate:+document.getElementById('cfgBitrate').value,
audio_bitrate:128
},
stream_outputs:[{urls:[document.getElementById('rtmpUrl').value+document.getElementById('rtmpKey').value]}],
compose_layers:layers.map(l=>({
participant_identity:l.id,
x:Math.round(l.x), y:Math.round(l.y),
width:Math.round(l.w), height:Math.round(l.h),
z_index:l.z
}))
};
}
function switchTab(t,el){
currentTab=t;
document.querySelectorAll('.tab').forEach(b=>b.classList.remove('active'));
el.classList.add('active'); updateCode();
}
function updateCode() {
const p=buildPayload();
const host=document.getElementById('lvkUrl').value.replace(/wss?:\/\//,'');
const key=document.getElementById('lvkKey').value;
const layerComments=p.compose_layers.map(l=>` <span class="cm">// ${l.participant_identity}: x=${l.x} y=${l.y} ${l.width}×${l.height} z=${l.z_index}</span>`).join('\n')||' <span class="cm">// (canvas vacío)</span>';
let html='';
if(currentTab==='curl'){
const body=JSON.stringify({room_name:p.room_name,layout:p.layout,stream_outputs:p.stream_outputs,options:p.options},null,2).replace(/</g,'&lt;').replace(/>/g,'&gt;');
html=`<span class="cm"># Ejecutar desde tu backend (nunca expongas el secret en cliente)</span>\n\n<span class="kw">curl</span> -X POST https://<span class="str">${host}</span>/twirp/livekit.Egress/StartRoomCompositeEgress \\\n -H <span class="str">"Authorization: Bearer &lt;JWT&gt;"</span> \\\n -H <span class="str">"Content-Type: application/json"</span> \\\n -d '<span class="str">${body}</span>'`;
} else if(currentTab==='js'){
html=`<span class="kw">import</span> { EgressClient } <span class="kw">from</span> <span class="str">'livekit-server-sdk'</span>;\n\n<span class="kw">const</span> egress = <span class="kw">new</span> <span class="fn">EgressClient</span>(<span class="str">'https://${host}'</span>, <span class="str">'${key}'</span>, secret);\n\n<span class="kw">const</span> info = <span class="kw">await</span> egress.<span class="fn">startRoomCompositeEgress</span>(\n <span class="str">'${p.room_name}'</span>,\n { <span class="prop">stream</span>: { <span class="prop">urls</span>: [<span class="str">'${p.stream_outputs[0].urls[0]}'</span>] } },\n {\n <span class="prop">layout</span>: <span class="str">'${p.layout}'</span>,\n <span class="prop">width</span>: ${p.options.width}, <span class="prop">height</span>: ${p.options.height},\n <span class="prop">framerate</span>: ${p.options.framerate},\n <span class="prop">videoBitrate</span>: ${p.options.video_bitrate},\n<span class="cm"> /* Capas del compose (posiciones lógicas 1920×1080) */</span>\n${layerComments}\n }\n);\nconsole.<span class="fn">log</span>(<span class="str">'Egress ID:'</span>, info.egressId);`;
} else {
html=`<span class="kw">client</span> := lksdk.<span class="fn">NewEgressClient</span>(<span class="str">"https://${host}"</span>, <span class="str">"${key}"</span>, secret)\n\nreq := &amp;lkproto.RoomCompositeEgressRequest{\n RoomName: <span class="str">"${p.room_name}"</span>,\n Layout: <span class="str">"${p.layout}"</span>,\n Output: &amp;lkproto.RoomCompositeEgressRequest_Stream{\n Stream: &amp;lkproto.StreamOutput{\n Urls: []<span class="kw">string</span>{<span class="str">"${p.stream_outputs[0].urls[0]}"</span>},\n },\n },\n Options: &amp;lkproto.RoomCompositeOptions{\n Width: ${p.options.width}, Height: ${p.options.height},\n Framerate: ${p.options.framerate}, VideoBitrate: ${p.options.video_bitrate},\n },\n}<span class="cm">// compose_layers count: ${p.compose_layers.length}</span>\ninfo, _ := client.<span class="fn">StartRoomCompositeEgress</span>(ctx, req)`;
}
document.getElementById('codeContent').innerHTML=html;
document.getElementById('eOverlays').textContent=layers.length;
}
function copyCode(){ navigator.clipboard.writeText(document.getElementById('codeContent').innerText).then(()=>toast('✓ Copiado')); }
// ════════════════════════════════════════
// HELPERS
// ════════════════════════════════════════
function updateHint(){ document.getElementById('canvasHint').style.display=layers.length?'none':'flex'; }
function updateResChip(){
document.getElementById('resChip').textContent=document.getElementById('cfgW').value+'×'+document.getElementById('cfgH').value;
}
function toast(msg,err=false){
const el=document.getElementById('toast');
el.textContent=msg; el.style.borderColor=err?'var(--accent2)':'var(--accent)'; el.style.color=err?'var(--accent2)':'var(--accent)';
el.classList.add('show'); clearTimeout(el._t); el._t=setTimeout(()=>el.classList.remove('show'),2800);
}
// deselect on empty stage click
document.getElementById('stage').addEventListener('mousedown',e=>{ if(e.target===document.getElementById('stage')) deselect(); });
// INIT
window.addEventListener('load',()=>{ resizeStage(); updateCode(); });
</script>
</body>
</html>