#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>
<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ıın. Odaya katılan herkese
otomatik çift yönlü ses bağlantısı kurulur (P2P WebRTC, max ~1015 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>

View File

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

View File

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

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

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