221 lines
7.4 KiB
JavaScript
221 lines
7.4 KiB
JavaScript
// 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;
|
||
}
|
||
}
|