#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>
|
<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ı açın. Odaya katılan herkese
|
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
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 ~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.
|
|
||||||
|
|
||||||
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;
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue