/** * 浏览器端本地录制:将通话区域内多路 video 合成到 canvas 后 MediaRecorder * 音频从各 video 的 srcObject 汇入 Web Audio 混音;Chrome 下 canvas 2D 的 captureStream 需每帧 requestFrame 才会稳定产出编码数据 */ function pickRecorderMime(): string { const candidates = [ 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm' ] for (const m of candidates) { if (MediaRecorder.isTypeSupported(m)) return m } return '' } function getAudioContextCtor(): (typeof AudioContext) | null { const w = window as unknown as { AudioContext?: typeof AudioContext webkitAudioContext?: typeof AudioContext } return w.AudioContext || w.webkitAudioContext || null } /** 2D canvas 的 captureStream 视频轨,需每帧 requestFrame(否则 Chrome 可能整段无编码数据) */ type CanvasCaptureVideoTrack = MediaStreamTrack & { requestFrame?: () => void } function drawCover( ctx: CanvasRenderingContext2D, v: HTMLVideoElement, x: number, y: number, w: number, h: number ) { const vw = v.videoWidth || v.clientWidth || 1 const vh = v.videoHeight || v.clientHeight || 1 const scale = Math.max(w / vw, h / vh) const dw = vw * scale const dh = vh * scale const dx = x + (w - dw) / 2 const dy = y + (h - dh) / 2 try { ctx.drawImage(v, dx, dy, dw, dh) } catch { /* 部分浏览器安全策略 */ } } function drawVideosGrid( ctx: CanvasRenderingContext2D, videos: HTMLVideoElement[], cw: number, ch: number ) { ctx.fillStyle = '#0b0b0b' ctx.fillRect(0, 0, cw, ch) const list = videos.filter((v) => v.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) if (list.length === 0) return if (list.length === 1) { drawCover(ctx, list[0], 0, 0, cw, ch) return } const cols = Math.ceil(Math.sqrt(list.length)) const rows = Math.ceil(list.length / cols) const cellW = cw / cols const cellH = ch / rows list.forEach((v, i) => { const c = i % cols const r = Math.floor(i / cols) drawCover(ctx, v, c * cellW, r * cellH, cellW, cellH) }) } function collectAudioTracksFromVideos(videos: HTMLVideoElement[]): MediaStreamTrack[] { const seen = new Set() const out: MediaStreamTrack[] = [] for (const v of videos) { const so = v.srcObject if (!(so instanceof MediaStream)) continue for (const t of so.getAudioTracks()) { if (t.kind !== 'audio' || t.readyState === 'ended' || !t.enabled) continue if (seen.has(t.id)) continue seen.add(t.id) out.push(t) } } return out } function collectAudioTracksFromCaptureStream(videos: HTMLVideoElement[]): MediaStreamTrack[] { const seen = new Set() const out: MediaStreamTrack[] = [] for (const v of videos) { if (v.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) continue const cap = (v as HTMLVideoElement & { captureStream?: () => MediaStream }).captureStream?.() if (!cap) continue for (const t of cap.getAudioTracks()) { if (t.kind !== 'audio' || t.readyState === 'ended' || !t.enabled) continue if (seen.has(t.id)) continue seen.add(t.id) out.push(t) } } return out } /** TRTC 等可能用独立