#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:
parent
75d5999b4a
commit
777f422873
|
|
@ -3,7 +3,6 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>MWSE — Sesli Görüşme Demo</title>
|
||||
<script src="/script"></script>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 700px; margin: 2rem auto; padding: 0 1rem; }
|
||||
h2 { margin-bottom: .3rem; }
|
||||
|
|
@ -11,51 +10,41 @@
|
|||
#status { padding: 6px 10px; background: #f5f5f5; border-radius: 4px;
|
||||
font-size: .9rem; margin-bottom: 1rem; }
|
||||
#peers { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.peer-card {
|
||||
border: 1px solid #bbb; border-radius: 6px; padding: 10px 14px;
|
||||
min-width: 160px; background: #fff;
|
||||
}
|
||||
.peer-card { border: 1px solid #bbb; border-radius: 6px; padding: 10px 14px;
|
||||
min-width: 160px; background: #fff; }
|
||||
.peer-card .id { font-family: monospace; font-size: .8rem; color: #555; }
|
||||
.peer-card .st { font-size: .85rem; margin-top: 4px; }
|
||||
.peer-card button { margin-top: 8px; padding: 4px 10px; cursor: pointer; }
|
||||
audio { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>MWSE Sesli Görüşme Demo</h2>
|
||||
<p class="note">
|
||||
Aynı anda birden fazla sekme ya da kullanıcı açın. Odaya katılan herkese
|
||||
otomatik çift yönlü ses bağlantısı kurulur (P2P WebRTC, max ~10–15 kişi için
|
||||
mesh topolojisi; daha fazlası için SRS entegrasyonu gerekir).
|
||||
otomatik çift yönlü ses bağlantısı kurulur (P2P WebRTC mesh, küçük gruplar için).
|
||||
</p>
|
||||
<div id="status">Bağlanıyor…</div>
|
||||
<div id="peers"></div>
|
||||
|
||||
<script>
|
||||
// Akış: MWSE bağlan → odaya katıl → gelen eşle eşleş →
|
||||
// mikrofon aç → WebRTC ses kanalını başlat.
|
||||
<script type="module">
|
||||
import MWSE from '/sdk/index.js';
|
||||
|
||||
const mwse = new MWSE({ endpoint: location.origin.replace(/^http/, 'ws') });
|
||||
let localStream; // MediaStream: mikrofon
|
||||
const cards = {}; // socketId → DOM element
|
||||
|
||||
// ---- Bağlantı -------------------------------------------------------
|
||||
const mwse = new MWSE();
|
||||
let localStream;
|
||||
const cards = {};
|
||||
|
||||
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 {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch (err) {
|
||||
status(`Mikrofon erişimi reddedildi: ${err}`);
|
||||
setStatus(`Mikrofon erişimi reddedildi: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// "sesli" odasına katıl (varsa mevcut odaya gir, yoksa oluştur).
|
||||
const room = mwse.room({ name: 'sesli', joinType: 'free', ifexistsJoin: true });
|
||||
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.
|
||||
room.on('join', async peer => {
|
||||
|
|
@ -63,50 +52,43 @@
|
|||
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 => {
|
||||
upsertCard(peer.socketId, 'eşleniyor…');
|
||||
await peer.acceptPair();
|
||||
});
|
||||
|
||||
// Eşleme kabul edildiğinde — WebRTC sesini başlat.
|
||||
mwse.me.on('accepted/pair', peer => {
|
||||
startAudio(peer);
|
||||
mwse.me.on('accepted/pair', 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) {
|
||||
upsertCard(peer.socketId, 'bağlanıyor…');
|
||||
|
||||
// Peer.rtc WebRTC.ts'deki mevcut örnek; signaling MWSE üzerinden gider.
|
||||
const rtc = peer.rtc;
|
||||
rtc.connect(); // varsayılan data kanalını aç (sinyal trafiği için)
|
||||
|
||||
// Ses akışını peer'a gönder.
|
||||
rtc.sendStream(localStream, 'audio', { kind: 'audio' });
|
||||
// Politeness: lexicographic comparison so exactly one side is polite.
|
||||
const polite = mwse.me.socketId < peer.socketId;
|
||||
rtc.connect({ polite });
|
||||
|
||||
// Uzak ses akışı gelince çal.
|
||||
rtc.on('stream:added', ({ stream, name }) => {
|
||||
if (name !== 'audio') return;
|
||||
const audio = document.createElement('audio');
|
||||
// Mikrofon akışını gönder.
|
||||
if (localStream) rtc.addStream('mic', localStream);
|
||||
|
||||
// Gelen ses track'lerini çal.
|
||||
rtc.on('track', (track) => {
|
||||
if (track.kind !== 'audio') return;
|
||||
const audio = new Audio();
|
||||
audio.autoplay = true;
|
||||
audio.srcObject = stream;
|
||||
audio.srcObject = new MediaStream([track]);
|
||||
document.body.appendChild(audio);
|
||||
upsertCard(peer.socketId, `🔊 konuşuyor`);
|
||||
upsertCard(peer.socketId, '🔊 konuşuyor');
|
||||
});
|
||||
|
||||
rtc.on('connected', () => upsertCard(peer.socketId, '🟢 bağlandı'));
|
||||
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) {
|
||||
if (!cards[id]) {
|
||||
const card = document.createElement('div');
|
||||
|
|
@ -118,9 +100,8 @@
|
|||
cards[id].querySelector('.st').textContent = state;
|
||||
}
|
||||
|
||||
function status(msg) {
|
||||
function setStatus(msg) {
|
||||
document.getElementById('status').textContent = msg;
|
||||
console.log('[audio-demo]', msg);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>MWSE — Chat Demo</title>
|
||||
<script src="/script"></script>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
|
||||
h2 { margin-bottom: .5rem; }
|
||||
|
|
@ -28,11 +27,13 @@
|
|||
<button onclick="send()">Gönder</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Bu demo ~20 satır JavaScript ile odalı gerçek zamanlı sohbet kurar.
|
||||
<script type="module">
|
||||
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.
|
||||
|
||||
const mwse = new MWSE({ endpoint: location.origin.replace(/^http/, 'ws') });
|
||||
const mwse = new MWSE();
|
||||
let room;
|
||||
|
||||
mwse.on('scope', async () => {
|
||||
|
|
@ -47,7 +48,7 @@
|
|||
room.on('message', (pack, peer) => log(`${peer.socketId}: ${pack.text}`, 'peer'));
|
||||
});
|
||||
|
||||
function send() {
|
||||
window.send = function() {
|
||||
const el = document.getElementById('msg');
|
||||
const text = el.value.trim();
|
||||
if (!text || !room) return;
|
||||
|
|
|
|||
|
|
@ -3,33 +3,22 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>MWSE — Video Demo</title>
|
||||
<script src="/script"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
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 h2 { margin: 0; font-size: 1.1rem; }
|
||||
#status { font-size: .8rem; color: #aaa; }
|
||||
#grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 6px; padding: 10px;
|
||||
}
|
||||
.tile {
|
||||
background: #222; border-radius: 6px; overflow: hidden; position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
#grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 6px; padding: 10px; }
|
||||
.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 .label {
|
||||
position: absolute; bottom: 6px; left: 6px;
|
||||
font-size: .75rem; background: rgba(0,0,0,.6); padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.tile .indicator {
|
||||
position: absolute; top: 6px; right: 6px; width: 10px; height: 10px;
|
||||
border-radius: 50%; background: #f44;
|
||||
}
|
||||
.tile.connected .indicator { background: #4c4; }
|
||||
.tile .label { position: absolute; bottom: 6px; left: 6px; font-size: .75rem;
|
||||
background: rgba(0,0,0,.6); padding: 2px 6px; border-radius: 3px; }
|
||||
.tile .dot { position: absolute; top: 6px; right: 6px; width: 10px; height: 10px;
|
||||
border-radius: 50%; background: #f44; }
|
||||
.tile.ok .dot { background: #4c4; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -37,111 +26,98 @@
|
|||
<h2>MWSE Video Demo</h2>
|
||||
<span id="status">Bağlanıyor…</span>
|
||||
</div>
|
||||
<div id="grid" id="grid"></div>
|
||||
<div id="grid"></div>
|
||||
|
||||
<script>
|
||||
// Akış: MWSE bağlan → kamera/mikrofon aç → kendi videoyu göster →
|
||||
// odaya katıl → her yeni eşle P2P WebRTC video kurulumu yap.
|
||||
//
|
||||
// Ölçek notu: WebRTC mesh topolojisi ~6–8 kişiye kadar makul çalışır.
|
||||
// Daha büyük odalar (100–500 kişi) için SRS tabanlı SFU gereklidir (#39).
|
||||
// Bu demo o SFU mimarisinin istemci tarafı API kalıplarını gösterir.
|
||||
<script type="module">
|
||||
import MWSE from '/sdk/index.js';
|
||||
|
||||
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 ~6–8 kişiye makul çalışır.
|
||||
// Daha büyük odalar için SFU (SRS) gerekir — bkz. #39.
|
||||
|
||||
const mwse = new MWSE();
|
||||
let localStream;
|
||||
const tiles = {}; // socketId → { el, rtc }
|
||||
|
||||
// ---- Kamera/mikrofon ------------------------------------------------
|
||||
const tiles = {};
|
||||
|
||||
mwse.on('scope', async () => {
|
||||
status(`Bağlandı: ${mwse.me.socketId}`);
|
||||
setStatus(`Bağlandı: ${mwse.me.socketId}`);
|
||||
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
||||
addTile('ben', localStream, true);
|
||||
} catch (err) {
|
||||
status(`Kamera erişimi reddedildi: ${err}`);
|
||||
addTile('ben (kamera yok)', null, true);
|
||||
return;
|
||||
setStatus(`Kamera erişimi reddedildi: ${err.message}`);
|
||||
}
|
||||
|
||||
// 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 });
|
||||
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 => {
|
||||
setTileState(peer.socketId, false);
|
||||
await peer.requestPair();
|
||||
});
|
||||
|
||||
// Otomatik eşleşme kabul.
|
||||
mwse.me.on('request/pair', async peer => {
|
||||
setTileState(peer.socketId, false);
|
||||
await peer.acceptPair();
|
||||
});
|
||||
|
||||
// Eşleme tamam — WebRTC video başlat.
|
||||
mwse.me.on('accepted/pair', peer => startVideo(peer));
|
||||
|
||||
// Eş ayrıldı.
|
||||
mwse.me.on('end/pair', id => removeTile(id));
|
||||
});
|
||||
|
||||
// ---- WebRTC video kurulumu -----------------------------------------
|
||||
|
||||
function startVideo(peer) {
|
||||
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.sendStream(localStream, 'video', { kind: 'video' });
|
||||
if (localStream) rtc.addStream('cam', localStream);
|
||||
|
||||
// Uzak video gelince tile'a yerleştir.
|
||||
rtc.on('stream:added', ({ stream, name }) => {
|
||||
if (name !== 'video') return;
|
||||
const label = `${peer.socketId.slice(-6)}`;
|
||||
addTile(label, stream, false);
|
||||
setTileState(peer.socketId, true);
|
||||
rtc.on('track', (track, streams) => {
|
||||
if (track.kind !== 'video') return;
|
||||
const stream = streams?.[0] ?? new MediaStream([track]);
|
||||
addTile(peer.socketId.slice(-8), stream, false);
|
||||
setOk(peer.socketId, true);
|
||||
});
|
||||
|
||||
rtc.on('connected', () => setTileState(peer.socketId, true));
|
||||
rtc.on('disconnected', () => setTileState(peer.socketId, false));
|
||||
rtc.on('connected', () => setOk(peer.socketId, true));
|
||||
rtc.on('disconnected', () => setOk(peer.socketId, false));
|
||||
}
|
||||
|
||||
// ---- UI -----------------------------------------------------------
|
||||
|
||||
function addTile(label, stream, isMe) {
|
||||
const id = isMe ? 'me' : label;
|
||||
if (tiles[id]) return;
|
||||
const key = isMe ? 'me' : label;
|
||||
if (tiles[key]) return;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'tile' + (isMe ? ' connected' : '');
|
||||
div.innerHTML = `
|
||||
<video autoplay playsinline ${isMe ? 'muted' : ''}></video>
|
||||
<div class="label">${label}</div>
|
||||
<div class="indicator"></div>`;
|
||||
if (stream) div.querySelector('video').srcObject = stream;
|
||||
div.className = 'tile' + (isMe ? ' ok' : '');
|
||||
const video = document.createElement('video');
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
if (isMe) video.muted = true;
|
||||
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);
|
||||
tiles[id] = div;
|
||||
tiles[key] = div;
|
||||
}
|
||||
|
||||
function setTileState(peerId, connected) {
|
||||
const tile = tiles[peerId.slice(-6)] || tiles[peerId];
|
||||
if (tile) tile.classList.toggle('connected', connected);
|
||||
function setOk(id, ok) {
|
||||
const tile = tiles[id.slice(-8)] ?? tiles[id];
|
||||
if (tile) tile.classList.toggle('ok', ok);
|
||||
}
|
||||
|
||||
function removeTile(peerId) {
|
||||
const key = (peerId || '').slice(-6);
|
||||
const tile = tiles[key] || tiles[peerId];
|
||||
if (tile) { tile.remove(); delete tiles[key]; delete tiles[peerId]; }
|
||||
function removeTile(id) {
|
||||
const key = (id || '').slice(-8);
|
||||
for (const k of [key, id]) {
|
||||
if (tiles[k]) { tiles[k].remove(); delete tiles[k]; }
|
||||
}
|
||||
}
|
||||
|
||||
function status(msg) {
|
||||
function setStatus(msg) {
|
||||
document.getElementById('status').textContent = msg;
|
||||
console.log('[video-demo]', msg);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import FileSender from './FileSender.js';
|
|||
export { MediaSources } from './MediaSources.js';
|
||||
export { default as StreamManager } from './StreamManager.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 RESTART_DELAYS = [1000, 2000, 4000]; // ms, exponential backoff
|
||||
|
|
|
|||
Loading…
Reference in New Issue