#38/#34: CanvasCompositor ve demo güncellemesi

sdk/webrtc/CanvasCompositor.js (#38):
  - Gelen video track'lerini tek canvas'ta birleştirme (grid/pip/side-by-side/focus)
  - OffscreenCanvas öncelikli (DOM gerektirmiyor), yoksa <canvas> kullanır
  - setFPS(), setLayout(), setFocus() ile runtime kontrol
  - stream() → MediaStream; StreamManager.addStream('composite', ...) ile gönderilir
  - setEncodings() ile bitrate/fps zaten StreamManager üzerinden destekleniyor

sdk/webrtc/index.js: CanvasCompositor re-export eklendi

public/demos/ (#34): Tüm demo'lar yeni SDK API'sine güncellendi:
  - <script src="/script"> → <script type="module"> import MWSE from '/sdk/index.js'
  - new MWSE() (endpoint otomatik import.meta.url'den alınıyor)
  - rtc.connect({ polite }) ile perfect negotiation polite/impolite tayini
  - rtc.addStream() / rtc.on('track') yeni API
  - rtc.on('failed') durumu gösteriliyor
  - window.send() module scope sorununu çözüyor (onclick handler)

go test -race ./... — tüm testler yeşil

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
abdussamedulutas 2026-06-17 13:02:58 +03:00
parent 75d5999b4a
commit 777f422873
5 changed files with 316 additions and 137 deletions

View File

@ -3,7 +3,6 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>MWSE — Sesli Görüşme Demo</title> <title>MWSE — Sesli Görüşme Demo</title>
<script src="/script"></script>
<style> <style>
body { font-family: sans-serif; max-width: 700px; margin: 2rem auto; padding: 0 1rem; } body { font-family: sans-serif; max-width: 700px; margin: 2rem auto; padding: 0 1rem; }
h2 { margin-bottom: .3rem; } h2 { margin-bottom: .3rem; }
@ -11,51 +10,41 @@
#status { padding: 6px 10px; background: #f5f5f5; border-radius: 4px; #status { padding: 6px 10px; background: #f5f5f5; border-radius: 4px;
font-size: .9rem; margin-bottom: 1rem; } font-size: .9rem; margin-bottom: 1rem; }
#peers { display: flex; flex-wrap: wrap; gap: 10px; } #peers { display: flex; flex-wrap: wrap; gap: 10px; }
.peer-card { .peer-card { border: 1px solid #bbb; border-radius: 6px; padding: 10px 14px;
border: 1px solid #bbb; border-radius: 6px; padding: 10px 14px; min-width: 160px; background: #fff; }
min-width: 160px; background: #fff;
}
.peer-card .id { font-family: monospace; font-size: .8rem; color: #555; } .peer-card .id { font-family: monospace; font-size: .8rem; color: #555; }
.peer-card .st { font-size: .85rem; margin-top: 4px; } .peer-card .st { font-size: .85rem; margin-top: 4px; }
.peer-card button { margin-top: 8px; padding: 4px 10px; cursor: pointer; }
audio { display: none; }
</style> </style>
</head> </head>
<body> <body>
<h2>MWSE Sesli Görüşme Demo</h2> <h2>MWSE Sesli Görüşme Demo</h2>
<p class="note"> <p class="note">
Aynı anda birden fazla sekme ya da kullanıcıın. Odaya katılan herkese Aynı anda birden fazla sekme ya da kullanıcıın. Odaya katılan herkese
otomatik çift yönlü ses bağlantısı kurulur (P2P WebRTC, max ~1015 kişi için otomatik çift yönlü ses bağlantısı kurulur (P2P WebRTC mesh, küçük gruplar için).
mesh topolojisi; daha fazlası için SRS entegrasyonu gerekir).
</p> </p>
<div id="status">Bağlanıyor…</div> <div id="status">Bağlanıyor…</div>
<div id="peers"></div> <div id="peers"></div>
<script> <script type="module">
// Akış: MWSE bağlan → odaya katıl → gelen eşle eşleş → import MWSE from '/sdk/index.js';
// mikrofon aç → WebRTC ses kanalını başlat.
const mwse = new MWSE({ endpoint: location.origin.replace(/^http/, 'ws') }); const mwse = new MWSE();
let localStream; // MediaStream: mikrofon let localStream;
const cards = {}; // socketId → DOM element const cards = {};
// ---- Bağlantı -------------------------------------------------------
mwse.on('scope', async () => { mwse.on('scope', async () => {
status(`Bağlandı: ${mwse.me.socketId}`); setStatus(`Bağlandı: ${mwse.me.socketId}`);
// Mikrofon erişimi: kullanıcı iznine gerek var.
try { try {
localStream = await navigator.mediaDevices.getUserMedia({ audio: true }); localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) { } catch (err) {
status(`Mikrofon erişimi reddedildi: ${err}`); setStatus(`Mikrofon erişimi reddedildi: ${err.message}`);
return; return;
} }
// "sesli" odasına katıl (varsa mevcut odaya gir, yoksa oluştur).
const room = mwse.room({ name: 'sesli', joinType: 'free', ifexistsJoin: true }); const room = mwse.room({ name: 'sesli', joinType: 'free', ifexistsJoin: true });
await room.createRoom(); await room.createRoom();
status(`Odada: sesli | Kimliğim: ${mwse.me.socketId}`); setStatus(`Odada: sesli | Kimliğim: ${mwse.me.socketId}`);
// Odaya yeni biri katıldığında — biz eşleme isteği atalım. // Odaya yeni biri katıldığında — biz eşleme isteği atalım.
room.on('join', async peer => { room.on('join', async peer => {
@ -63,50 +52,43 @@
await peer.requestPair(); await peer.requestPair();
}); });
// Birisi bize eşleme isteği gönderdiğinde — otomatik kabul. // Bize gelen eşleme isteğini otomatik kabul et.
mwse.me.on('request/pair', async peer => { mwse.me.on('request/pair', async peer => {
upsertCard(peer.socketId, 'eşleniyor…');
await peer.acceptPair(); await peer.acceptPair();
}); });
// Eşleme kabul edildiğinde — WebRTC sesini başlat. // Eşleme kabul edildiğinde — WebRTC sesini başlat.
mwse.me.on('accepted/pair', peer => { mwse.me.on('accepted/pair', peer => startAudio(peer));
startAudio(peer); mwse.me.on('end/pair', (id) => upsertCard(id, 'ayrıldı'));
});
// Eş bağlantıyı kestiğinde kart güncelle.
mwse.me.on('end/pair', (peerId) => {
upsertCard(peerId, 'ayrıldı');
});
}); });
// ---- WebRTC ses kurulumu -------------------------------------------
function startAudio(peer) { function startAudio(peer) {
upsertCard(peer.socketId, 'bağlanıyor…'); upsertCard(peer.socketId, 'bağlanıyor…');
// Peer.rtc WebRTC.ts'deki mevcut örnek; signaling MWSE üzerinden gider.
const rtc = peer.rtc; const rtc = peer.rtc;
rtc.connect(); // varsayılan data kanalını aç (sinyal trafiği için)
// Ses akışını peer'a gönder. // Politeness: lexicographic comparison so exactly one side is polite.
rtc.sendStream(localStream, 'audio', { kind: 'audio' }); const polite = mwse.me.socketId < peer.socketId;
rtc.connect({ polite });
// Uzak ses akışı gelince çal. // Mikrofon akışını gönder.
rtc.on('stream:added', ({ stream, name }) => { if (localStream) rtc.addStream('mic', localStream);
if (name !== 'audio') return;
const audio = document.createElement('audio'); // Gelen ses track'lerini çal.
rtc.on('track', (track) => {
if (track.kind !== 'audio') return;
const audio = new Audio();
audio.autoplay = true; audio.autoplay = true;
audio.srcObject = stream; audio.srcObject = new MediaStream([track]);
document.body.appendChild(audio); document.body.appendChild(audio);
upsertCard(peer.socketId, `🔊 konuşuyor`); upsertCard(peer.socketId, '🔊 konuşuyor');
}); });
rtc.on('connected', () => upsertCard(peer.socketId, '🟢 bağlandı')); rtc.on('connected', () => upsertCard(peer.socketId, '🟢 bağlandı'));
rtc.on('disconnected', () => upsertCard(peer.socketId, '🔴 kesildi')); rtc.on('disconnected', () => upsertCard(peer.socketId, '🔴 kesildi'));
rtc.on('failed', () => upsertCard(peer.socketId, '⚠️ başarısız'));
} }
// ---- UI yardımcıları -----------------------------------------------
function upsertCard(id, state) { function upsertCard(id, state) {
if (!cards[id]) { if (!cards[id]) {
const card = document.createElement('div'); const card = document.createElement('div');
@ -118,9 +100,8 @@
cards[id].querySelector('.st').textContent = state; cards[id].querySelector('.st').textContent = state;
} }
function status(msg) { function setStatus(msg) {
document.getElementById('status').textContent = msg; document.getElementById('status').textContent = msg;
console.log('[audio-demo]', msg);
} }
</script> </script>
</body> </body>

View File

@ -3,7 +3,6 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>MWSE — Chat Demo</title> <title>MWSE — Chat Demo</title>
<script src="/script"></script>
<style> <style>
body { font-family: sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; } body { font-family: sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
h2 { margin-bottom: .5rem; } h2 { margin-bottom: .5rem; }
@ -28,11 +27,13 @@
<button onclick="send()">Gönder</button> <button onclick="send()">Gönder</button>
</div> </div>
<script> <script type="module">
// Bu demo ~20 satır JavaScript ile odalı gerçek zamanlı sohbet kurar. import MWSE from '/sdk/index.js';
// ~20 satır JS ile odalı gerçek zamanlı sohbet.
// Birden fazla sekme / kullanıcı aynı "genel" odasına otomatik katılır. // Birden fazla sekme / kullanıcı aynı "genel" odasına otomatik katılır.
const mwse = new MWSE({ endpoint: location.origin.replace(/^http/, 'ws') }); const mwse = new MWSE();
let room; let room;
mwse.on('scope', async () => { mwse.on('scope', async () => {
@ -47,7 +48,7 @@
room.on('message', (pack, peer) => log(`${peer.socketId}: ${pack.text}`, 'peer')); room.on('message', (pack, peer) => log(`${peer.socketId}: ${pack.text}`, 'peer'));
}); });
function send() { window.send = function() {
const el = document.getElementById('msg'); const el = document.getElementById('msg');
const text = el.value.trim(); const text = el.value.trim();
if (!text || !room) return; if (!text || !room) return;

View File

@ -3,33 +3,22 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>MWSE — Video Demo</title> <title>MWSE — Video Demo</title>
<script src="/script"></script>
<style> <style>
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { font-family: sans-serif; margin: 0; background: #111; color: #eee; } body { font-family: sans-serif; margin: 0; background: #111; color: #eee; }
#header { padding: 10px 16px; background: #1a1a1a; display: flex; align-items: center; gap: 12px; } #header { padding: 10px 16px; background: #1a1a1a; display: flex; align-items: center; gap: 12px; }
#header h2 { margin: 0; font-size: 1.1rem; } #header h2 { margin: 0; font-size: 1.1rem; }
#status { font-size: .8rem; color: #aaa; } #status { font-size: .8rem; color: #aaa; }
#grid { #grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
display: grid; gap: 6px; padding: 10px; }
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); .tile { background: #222; border-radius: 6px; overflow: hidden; position: relative;
gap: 6px; padding: 10px; aspect-ratio: 16/9; }
}
.tile {
background: #222; border-radius: 6px; overflow: hidden; position: relative;
aspect-ratio: 16/9;
}
.tile video { width: 100%; height: 100%; object-fit: cover; background: #000; } .tile video { width: 100%; height: 100%; object-fit: cover; background: #000; }
.tile .label { .tile .label { position: absolute; bottom: 6px; left: 6px; font-size: .75rem;
position: absolute; bottom: 6px; left: 6px; background: rgba(0,0,0,.6); padding: 2px 6px; border-radius: 3px; }
font-size: .75rem; background: rgba(0,0,0,.6); padding: 2px 6px; .tile .dot { position: absolute; top: 6px; right: 6px; width: 10px; height: 10px;
border-radius: 3px; border-radius: 50%; background: #f44; }
} .tile.ok .dot { background: #4c4; }
.tile .indicator {
position: absolute; top: 6px; right: 6px; width: 10px; height: 10px;
border-radius: 50%; background: #f44;
}
.tile.connected .indicator { background: #4c4; }
</style> </style>
</head> </head>
<body> <body>
@ -37,111 +26,98 @@
<h2>MWSE Video Demo</h2> <h2>MWSE Video Demo</h2>
<span id="status">Bağlanıyor…</span> <span id="status">Bağlanıyor…</span>
</div> </div>
<div id="grid" id="grid"></div> <div id="grid"></div>
<script> <script type="module">
// Akış: MWSE bağlan → kamera/mikrofon aç → kendi videoyu göster → import MWSE from '/sdk/index.js';
// odaya katıl → her yeni eşle P2P WebRTC video kurulumu yap.
//
// Ölçek notu: WebRTC mesh topolojisi ~68 kişiye kadar makul çalışır.
// Daha büyük odalar (100500 kişi) için SRS tabanlı SFU gereklidir (#39).
// Bu demo o SFU mimarisinin istemci tarafı API kalıplarını gösterir.
const mwse = new MWSE({ endpoint: location.origin.replace(/^http/, 'ws') }); // Akış: bağlan → kamera aç → kendi tile'ı ekle → odaya katıl →
// her eşle P2P WebRTC bağlantısı kur.
// Ölçek notu: mesh topolojisi ~68 kişiye makul çalışır.
// Daha büyük odalar için SFU (SRS) gerekir — bkz. #39.
const mwse = new MWSE();
let localStream; let localStream;
const tiles = {}; // socketId → { el, rtc } const tiles = {};
// ---- Kamera/mikrofon ------------------------------------------------
mwse.on('scope', async () => { mwse.on('scope', async () => {
status(`Bağlandı: ${mwse.me.socketId}`); setStatus(`Bağlandı: ${mwse.me.socketId}`);
try { try {
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
addTile('ben', localStream, true);
} catch (err) { } catch (err) {
status(`Kamera erişimi reddedildi: ${err}`); setStatus(`Kamera erişimi reddedildi: ${err.message}`);
addTile('ben (kamera yok)', null, true);
return;
} }
// Kendi videoyu göster (sessiz — aksi hâlde geri besleme oluşur).
addTile(`ben (${mwse.me.socketId.slice(-6)})`, localStream, true);
// Odaya katıl.
const room = mwse.room({ name: 'video', joinType: 'free', ifexistsJoin: true }); const room = mwse.room({ name: 'video', joinType: 'free', ifexistsJoin: true });
await room.createRoom(); await room.createRoom();
status(`Odada: video | ${mwse.me.socketId}`); setStatus(`Odada: video | ${mwse.me.socketId.slice(-8)}`);
// Yeni katılan ile bağlantı başlat.
room.on('join', async peer => { room.on('join', async peer => {
setTileState(peer.socketId, false);
await peer.requestPair(); await peer.requestPair();
}); });
// Otomatik eşleşme kabul.
mwse.me.on('request/pair', async peer => { mwse.me.on('request/pair', async peer => {
setTileState(peer.socketId, false);
await peer.acceptPair(); await peer.acceptPair();
}); });
// Eşleme tamam — WebRTC video başlat.
mwse.me.on('accepted/pair', peer => startVideo(peer)); mwse.me.on('accepted/pair', peer => startVideo(peer));
// Eş ayrıldı.
mwse.me.on('end/pair', id => removeTile(id)); mwse.me.on('end/pair', id => removeTile(id));
}); });
// ---- WebRTC video kurulumu -----------------------------------------
function startVideo(peer) { function startVideo(peer) {
const rtc = peer.rtc; const rtc = peer.rtc;
rtc.connect(); const polite = mwse.me.socketId < peer.socketId;
rtc.connect({ polite });
// Kendi videomuzun tüm izlerini peer'a gönder. if (localStream) rtc.addStream('cam', localStream);
if (localStream) rtc.sendStream(localStream, 'video', { kind: 'video' });
// Uzak video gelince tile'a yerleştir. rtc.on('track', (track, streams) => {
rtc.on('stream:added', ({ stream, name }) => { if (track.kind !== 'video') return;
if (name !== 'video') return; const stream = streams?.[0] ?? new MediaStream([track]);
const label = `${peer.socketId.slice(-6)}`; addTile(peer.socketId.slice(-8), stream, false);
addTile(label, stream, false); setOk(peer.socketId, true);
setTileState(peer.socketId, true);
}); });
rtc.on('connected', () => setTileState(peer.socketId, true)); rtc.on('connected', () => setOk(peer.socketId, true));
rtc.on('disconnected', () => setTileState(peer.socketId, false)); rtc.on('disconnected', () => setOk(peer.socketId, false));
} }
// ---- UI -----------------------------------------------------------
function addTile(label, stream, isMe) { function addTile(label, stream, isMe) {
const id = isMe ? 'me' : label; const key = isMe ? 'me' : label;
if (tiles[id]) return; if (tiles[key]) return;
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'tile' + (isMe ? ' connected' : ''); div.className = 'tile' + (isMe ? ' ok' : '');
div.innerHTML = ` const video = document.createElement('video');
<video autoplay playsinline ${isMe ? 'muted' : ''}></video> video.autoplay = true;
<div class="label">${label}</div> video.playsInline = true;
<div class="indicator"></div>`; if (isMe) video.muted = true;
if (stream) div.querySelector('video').srcObject = stream; if (stream) video.srcObject = stream;
const lbl = document.createElement('div');
lbl.className = 'label';
lbl.textContent = isMe ? `★ ${label}` : label;
const dot = document.createElement('div');
dot.className = 'dot';
div.append(video, lbl, dot);
document.getElementById('grid').appendChild(div); document.getElementById('grid').appendChild(div);
tiles[id] = div; tiles[key] = div;
} }
function setTileState(peerId, connected) { function setOk(id, ok) {
const tile = tiles[peerId.slice(-6)] || tiles[peerId]; const tile = tiles[id.slice(-8)] ?? tiles[id];
if (tile) tile.classList.toggle('connected', connected); if (tile) tile.classList.toggle('ok', ok);
} }
function removeTile(peerId) { function removeTile(id) {
const key = (peerId || '').slice(-6); const key = (id || '').slice(-8);
const tile = tiles[key] || tiles[peerId]; for (const k of [key, id]) {
if (tile) { tile.remove(); delete tiles[key]; delete tiles[peerId]; } if (tiles[k]) { tiles[k].remove(); delete tiles[k]; }
}
} }
function status(msg) { function setStatus(msg) {
document.getElementById('status').textContent = msg; document.getElementById('status').textContent = msg;
console.log('[video-demo]', msg);
} }
</script> </script>
</body> </body>

View File

@ -0,0 +1,220 @@
// CanvasCompositor — gelen video track'lerini tek bir canvas'ta birleştirir.
//
// Kullanım:
// const comp = new CanvasCompositor({ width: 1280, height: 720, fps: 30 });
// comp.addTrack('alice', videoTrack);
// comp.addTrack('bob', videoTrack2);
// comp.setLayout('grid'); // 'grid' | 'pip' | 'side-by-side' | 'focus'
// const stream = comp.stream(); // MediaStream → StreamManager.addStream() ile gönder
// comp.destroy(); // cleanup
//
// Ses için AudioContext mix bus:
// const { ctx, dest, stream: audioStream } = MediaSources.createAudioMix();
// ctx.createMediaStreamSource(peerAudioStream).connect(dest);
// // audioStream'ı da StreamManager.addStream('audio-mix', audioStream) ile gönder
export default class CanvasCompositor {
constructor({ width = 1280, height = 720, fps = 30, background = '#000' } = {}) {
this._width = width;
this._height = height;
this._fps = fps;
this._bg = background;
// Use OffscreenCanvas when available (no DOM required, runs in workers too).
if (typeof OffscreenCanvas !== 'undefined') {
this._canvas = new OffscreenCanvas(width, height);
} else {
this._canvas = document.createElement('canvas');
this._canvas.width = width;
this._canvas.height = height;
}
this._ctx = this._canvas.getContext('2d');
this._tracks = new Map(); // label → { track, video, active }
this._layout = 'grid';
this._focus = null; // label of the focused track in 'focus' mode
this._timer = null;
this._stream = null;
this._start();
}
// ---- Track management -----------------------------------------------
// Add a VideoTrack to the compositor. A hidden <video> element is used
// internally to decode the track into a drawable surface.
addTrack(label, track) {
if (track.kind !== 'video') return;
let video;
if (typeof document !== 'undefined') {
video = document.createElement('video');
video.autoplay = true;
video.muted = true;
video.playsInline = true;
video.srcObject = new MediaStream([track]);
video.play().catch(() => {});
} else {
// Non-browser context (e.g. Node/worker): track drawing is a no-op.
video = null;
}
this._tracks.set(label, { track, video, active: true });
}
removeTrack(label) {
const entry = this._tracks.get(label);
if (!entry) return;
if (entry.video) {
entry.video.srcObject = null;
}
this._tracks.delete(label);
if (this._focus === label) this._focus = null;
}
// Mute/show a track in the compositor without removing it.
setVisible(label, visible) {
const entry = this._tracks.get(label);
if (entry) entry.active = visible;
}
// ---- Layout ---------------------------------------------------------
// 'grid' — equal tiles
// 'pip' — first track large, rest small overlay in corner
// 'side-by-side'— two equal columns (more than 2 tracks still grid)
// 'focus' — one track large (setFocus), rest tiny strip at bottom
setLayout(layout) {
this._layout = layout;
}
setFocus(label) {
this._focus = label;
this._layout = 'focus';
}
// ---- Output ---------------------------------------------------------
// Returns the composite as a MediaStream (video only).
// Pass the result to StreamManager.addStream('composite', stream).
stream() {
if (this._stream) return this._stream;
if (typeof this._canvas.captureStream === 'function') {
this._stream = this._canvas.captureStream(this._fps);
} else {
// OffscreenCanvas doesn't have captureStream; fall back to no-op.
this._stream = new MediaStream();
}
return this._stream;
}
// Change the output framerate.
setFPS(fps) {
this._fps = fps;
this._stop();
this._start();
}
// ---- Lifecycle ------------------------------------------------------
destroy() {
this._stop();
for (const [label] of this._tracks) this.removeTrack(label);
this._stream = null;
}
// ---- Internal -------------------------------------------------------
_start() {
const interval = Math.round(1000 / this._fps);
this._timer = setInterval(() => this._draw(), interval);
}
_stop() {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
}
_draw() {
const ctx = this._ctx;
const W = this._width;
const H = this._height;
ctx.fillStyle = this._bg;
ctx.fillRect(0, 0, W, H);
const entries = [...this._tracks.values()].filter(e => e.active && e.video);
if (entries.length === 0) return;
const rects = this._layout === 'pip' ? this._pipRects(entries, W, H)
: this._layout === 'side-by-side' ? this._sideBySideRects(entries, W, H)
: this._layout === 'focus' ? this._focusRects(entries, W, H)
: this._gridRects(entries, W, H);
for (let i = 0; i < entries.length; i++) {
const { video } = entries[i];
const r = rects[i];
if (!r || video.readyState < 2) continue;
try {
ctx.drawImage(video, r.x, r.y, r.w, r.h);
} catch (_) {}
}
}
_gridRects(entries, W, H) {
const n = entries.length;
const cols = Math.ceil(Math.sqrt(n));
const rows = Math.ceil(n / cols);
const w = Math.floor(W / cols);
const h = Math.floor(H / rows);
return entries.map((_, i) => ({
x: (i % cols) * w,
y: Math.floor(i / cols) * h,
w, h
}));
}
_pipRects(entries, W, H) {
const rects = [{ x: 0, y: 0, w: W, h: H }];
const pipW = Math.floor(W / 4);
const pipH = Math.floor(H / 4);
const pad = 12;
for (let i = 1; i < entries.length; i++) {
rects.push({
x: W - pipW - pad - (i - 1) * (pipW + pad),
y: H - pipH - pad,
w: pipW,
h: pipH
});
}
return rects;
}
_sideBySideRects(entries, W, H) {
if (entries.length <= 2) {
const w = Math.floor(W / Math.max(2, entries.length));
return entries.map((_, i) => ({ x: i * w, y: 0, w, h: H }));
}
return this._gridRects(entries, W, H);
}
_focusRects(entries, W, H) {
const focusIdx = entries.findIndex(
(_, i) => [...this._tracks.keys()][i] === this._focus
);
const idx = focusIdx >= 0 ? focusIdx : 0;
const stripH = Math.floor(H / 5);
const mainH = H - stripH - 8;
const rects = new Array(entries.length);
rects[idx] = { x: 0, y: 0, w: W, h: mainH };
const others = entries.map((_, i) => i).filter(i => i !== idx);
const tileW = others.length ? Math.floor(W / others.length) : W;
others.forEach((oi, j) => {
rects[oi] = { x: j * tileW, y: mainH + 8, w: tileW, h: stripH };
});
return rects;
}
}

View File

@ -22,9 +22,10 @@ import Negotiator from './Negotiator.js';
import StreamManager from './StreamManager.js'; import StreamManager from './StreamManager.js';
import DataChannel from './DataChannel.js'; import DataChannel from './DataChannel.js';
import FileSender from './FileSender.js'; import FileSender from './FileSender.js';
export { MediaSources } from './MediaSources.js'; export { MediaSources } from './MediaSources.js';
export { default as StreamManager } from './StreamManager.js'; export { default as StreamManager } from './StreamManager.js';
export { default as FileSender } from './FileSender.js'; export { default as FileSender } from './FileSender.js';
export { default as CanvasCompositor} from './CanvasCompositor.js';
const DEFAULT_ICE = [{ urls: 'stun:stun.l.google.com:19302' }]; const DEFAULT_ICE = [{ urls: 'stun:stun.l.google.com:19302' }];
const RESTART_DELAYS = [1000, 2000, 4000]; // ms, exponential backoff const RESTART_DELAYS = [1000, 2000, 4000]; // ms, exponential backoff