481 lines
14 KiB
TypeScript
481 lines
14 KiB
TypeScript
/**
|
|
* 浏览器端本地录制:将通话区域内多路 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<string>()
|
|
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<string>()
|
|
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 等可能用独立 <audio> 播远端,不挂在 video 的 srcObject 上 */
|
|
function collectAudioTracksFromAudioElements(root?: HTMLElement | null): MediaStreamTrack[] {
|
|
const seen = new Set<string>()
|
|
const out: MediaStreamTrack[] = []
|
|
const lists: HTMLAudioElement[] = [
|
|
...document.body.querySelectorAll('audio')
|
|
] as HTMLAudioElement[]
|
|
if (root) lists.push(...(root.querySelectorAll('audio') as unknown as HTMLAudioElement[]))
|
|
const kit = document.getElementById('tuicallkit-id')
|
|
if (kit) lists.push(...(kit.querySelectorAll('audio') as unknown as HTMLAudioElement[]))
|
|
const dedupEl = new Set<Element>()
|
|
for (const a of lists) {
|
|
if (dedupEl.has(a)) continue
|
|
dedupEl.add(a)
|
|
const so = a.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 resolveRecordingVideos(preferred: HTMLElement): HTMLVideoElement[] {
|
|
const seen = new Set<Element>()
|
|
const uniq = (list: HTMLVideoElement[]) => {
|
|
const out: HTMLVideoElement[] = []
|
|
for (const v of list) {
|
|
if (seen.has(v)) continue
|
|
seen.add(v)
|
|
out.push(v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
const fromPreferred = [...preferred.querySelectorAll('video')] as HTMLVideoElement[]
|
|
if (fromPreferred.length > 0) return uniq(fromPreferred)
|
|
|
|
const kit = document.getElementById('tuicallkit-id')
|
|
if (kit) {
|
|
const fromKit = [...kit.querySelectorAll('video')] as HTMLVideoElement[]
|
|
if (fromKit.length > 0) return uniq(fromKit)
|
|
}
|
|
|
|
const bodyVideos = [...document.body.querySelectorAll('video')] as HTMLVideoElement[]
|
|
const withLiveVideo = bodyVideos.filter((v) => {
|
|
const so = v.srcObject
|
|
if (!(so instanceof MediaStream)) return false
|
|
return so.getVideoTracks().some((t) => t.readyState === 'live')
|
|
})
|
|
return uniq(withLiveVideo)
|
|
}
|
|
|
|
export class CallLocalRecorder {
|
|
private canvas: HTMLCanvasElement | null = null
|
|
private ctx: CanvasRenderingContext2D | null = null
|
|
private rafId = 0
|
|
private container: HTMLElement | null = null
|
|
private mediaRecorder: MediaRecorder | null = null
|
|
private chunks: Blob[] = []
|
|
private mimeType = ''
|
|
private audioCtx: AudioContext | null = null
|
|
private audioDestination: MediaStreamAudioDestinationNode | null = null
|
|
private wiredTrackIds = new Set<string>()
|
|
private canvasVideoTrack: CanvasCaptureVideoTrack | null = null
|
|
private silentOsc: OscillatorNode | null = null
|
|
private silentGain: GainNode | null = null
|
|
/** 本机麦克风:TRTC 常不把本地麦混进远端画面用的 MediaStream,需单独拉一路混音 */
|
|
private micStream: MediaStream | null = null
|
|
private stopResultPromise: Promise<Blob | null> | null = null
|
|
|
|
isRecording(): boolean {
|
|
return this.mediaRecorder !== null && this.mediaRecorder.state === 'recording'
|
|
}
|
|
|
|
beginStopNow(): void {
|
|
if (this.stopResultPromise) return
|
|
|
|
cancelAnimationFrame(this.rafId)
|
|
this.rafId = 0
|
|
|
|
const mr = this.mediaRecorder
|
|
const mimeFallback = this.mimeType
|
|
|
|
if (!mr || mr.state === 'inactive') {
|
|
return
|
|
}
|
|
|
|
this.stopResultPromise = new Promise((resolve) => {
|
|
mr.onstop = () => {
|
|
this.teardownAudioGraph()
|
|
const type = mr.mimeType || mimeFallback || 'video/webm'
|
|
const blob =
|
|
this.chunks.length > 0 ? new Blob(this.chunks, { type }) : null
|
|
this.chunks = []
|
|
this.mediaRecorder = null
|
|
this.container = null
|
|
this.canvas = null
|
|
this.ctx = null
|
|
this.canvasVideoTrack = null
|
|
resolve(blob && blob.size > 0 ? blob : null)
|
|
}
|
|
try {
|
|
const m = mr as MediaRecorder & { requestData?: () => void }
|
|
m.requestData?.()
|
|
mr.stop()
|
|
} catch {
|
|
this.teardownAudioGraph()
|
|
this.chunks = []
|
|
this.mediaRecorder = null
|
|
this.container = null
|
|
this.canvas = null
|
|
this.ctx = null
|
|
this.canvasVideoTrack = null
|
|
resolve(null)
|
|
}
|
|
})
|
|
}
|
|
|
|
private teardownAudioGraph(): void {
|
|
try {
|
|
this.silentOsc?.stop()
|
|
} catch {
|
|
/* */
|
|
}
|
|
this.silentOsc = null
|
|
this.silentGain = null
|
|
if (this.micStream) {
|
|
for (const t of this.micStream.getTracks()) {
|
|
try {
|
|
t.stop()
|
|
} catch {
|
|
/* */
|
|
}
|
|
}
|
|
this.micStream = null
|
|
}
|
|
this.wiredTrackIds.clear()
|
|
this.audioDestination = null
|
|
if (this.audioCtx) {
|
|
void this.audioCtx.close().catch(() => {})
|
|
this.audioCtx = null
|
|
}
|
|
}
|
|
|
|
private connectTrackToMixer(track: MediaStreamTrack): void {
|
|
if (track.kind !== 'audio' || track.readyState === 'ended' || !track.enabled) return
|
|
if (!this.audioCtx || !this.audioDestination) return
|
|
if (this.wiredTrackIds.has(track.id)) return
|
|
try {
|
|
const src = this.audioCtx.createMediaStreamSource(new MediaStream([track]))
|
|
const gain = this.audioCtx.createGain()
|
|
gain.gain.value = 1.25
|
|
src.connect(gain)
|
|
gain.connect(this.audioDestination)
|
|
this.wiredTrackIds.add(track.id)
|
|
} catch {
|
|
/* */
|
|
}
|
|
}
|
|
|
|
/** 接通后单独混本机麦(与画面里的远端轨并存,避免「只有画面没医生声音」) */
|
|
private attachMicForMixer(): void {
|
|
if (!this.audioCtx || !this.audioDestination) return
|
|
const md = navigator.mediaDevices
|
|
if (!md?.getUserMedia) return
|
|
void md.getUserMedia({ audio: true, video: false }).then((stream) => {
|
|
if (!this.audioDestination || !this.audioCtx) {
|
|
stream.getTracks().forEach((t) => t.stop())
|
|
return
|
|
}
|
|
if (this.micStream) {
|
|
stream.getTracks().forEach((t) => t.stop())
|
|
return
|
|
}
|
|
this.micStream = stream
|
|
for (const t of stream.getAudioTracks()) {
|
|
this.connectTrackToMixer(t)
|
|
}
|
|
}).catch(() => {
|
|
/* 无权限或已占用时仅录远端 */
|
|
})
|
|
}
|
|
|
|
private wireIncomingAudio(): void {
|
|
if (!this.audioCtx || !this.audioDestination || !this.container) return
|
|
if (this.audioCtx.state === 'suspended') {
|
|
void this.audioCtx.resume().catch(() => {})
|
|
}
|
|
const videos = resolveRecordingVideos(this.container)
|
|
const kit = document.getElementById('tuicallkit-id')
|
|
const audioTags = [
|
|
...document.body.querySelectorAll('audio'),
|
|
...this.container.querySelectorAll('audio'),
|
|
...(kit ? kit.querySelectorAll('audio') : [])
|
|
] as HTMLAudioElement[]
|
|
|
|
const seenEl = new Set<Element>()
|
|
const mediaEls: HTMLMediaElement[] = []
|
|
for (const v of videos) {
|
|
if (!seenEl.has(v)) {
|
|
seenEl.add(v)
|
|
mediaEls.push(v)
|
|
}
|
|
}
|
|
for (const a of audioTags) {
|
|
if (!seenEl.has(a)) {
|
|
seenEl.add(a)
|
|
mediaEls.push(a)
|
|
}
|
|
}
|
|
|
|
for (const el of mediaEls) {
|
|
const so = el.srcObject
|
|
if (so instanceof MediaStream) {
|
|
for (const t of so.getAudioTracks()) {
|
|
this.connectTrackToMixer(t)
|
|
}
|
|
}
|
|
if (el instanceof HTMLVideoElement) {
|
|
if (el.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
const cap = el.captureStream?.()
|
|
if (cap) {
|
|
for (const t of cap.getAudioTracks()) {
|
|
this.connectTrackToMixer(t)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
reset(): void {
|
|
cancelAnimationFrame(this.rafId)
|
|
this.rafId = 0
|
|
this.stopResultPromise = null
|
|
this.canvasVideoTrack = null
|
|
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
|
|
try {
|
|
this.mediaRecorder.stop()
|
|
} catch {
|
|
/* */
|
|
}
|
|
}
|
|
this.mediaRecorder = null
|
|
this.chunks = []
|
|
this.canvas = null
|
|
this.ctx = null
|
|
this.container = null
|
|
this.mimeType = ''
|
|
this.teardownAudioGraph()
|
|
}
|
|
|
|
start(container: HTMLElement): boolean {
|
|
if (typeof MediaRecorder === 'undefined') return false
|
|
this.reset()
|
|
this.container = container
|
|
const mime = pickRecorderMime()
|
|
this.mimeType = mime || 'video/webm'
|
|
|
|
this.canvas = document.createElement('canvas')
|
|
this.canvas.width = 1280
|
|
this.canvas.height = 720
|
|
this.ctx = this.canvas.getContext('2d', { alpha: false })
|
|
if (!this.ctx) return false
|
|
|
|
const canvasVideoStream = this.canvas.captureStream(30)
|
|
const videoTracks = canvasVideoStream.getVideoTracks()
|
|
this.canvasVideoTrack = (videoTracks[0] as CanvasCaptureVideoTrack) ?? null
|
|
|
|
const videos = resolveRecordingVideos(container)
|
|
const AudioCtxCtor = getAudioContextCtor()
|
|
let outStream: MediaStream
|
|
|
|
if (AudioCtxCtor) {
|
|
try {
|
|
this.audioCtx = new AudioCtxCtor()
|
|
this.audioDestination = this.audioCtx.createMediaStreamDestination()
|
|
void this.audioCtx.resume().catch(() => {})
|
|
this.wireIncomingAudio()
|
|
this.attachMicForMixer()
|
|
const mixed = this.audioDestination.stream.getAudioTracks()
|
|
// 极低电平直流激励,避免「仅 WebRTC 音轨 + suspended/间歇」时 mux 无输出
|
|
const osc = this.audioCtx.createOscillator()
|
|
const gain = this.audioCtx.createGain()
|
|
gain.gain.value = 1e-5
|
|
osc.frequency.value = 20
|
|
osc.connect(gain)
|
|
gain.connect(this.audioDestination)
|
|
osc.start()
|
|
this.silentOsc = osc
|
|
this.silentGain = gain
|
|
outStream = new MediaStream([...videoTracks, ...mixed])
|
|
} catch {
|
|
this.teardownAudioGraph()
|
|
outStream = this.buildStreamWithoutMixer(videoTracks, videos)
|
|
}
|
|
} else {
|
|
outStream = this.buildStreamWithoutMixer(videoTracks, videos)
|
|
}
|
|
|
|
try {
|
|
this.mediaRecorder = mime
|
|
? new MediaRecorder(outStream, { mimeType: mime })
|
|
: new MediaRecorder(outStream)
|
|
} catch {
|
|
try {
|
|
this.mediaRecorder = new MediaRecorder(outStream)
|
|
} catch {
|
|
this.reset()
|
|
return false
|
|
}
|
|
}
|
|
|
|
this.chunks = []
|
|
this.mediaRecorder.ondataavailable = (e) => {
|
|
if (e.data && e.data.size > 0) this.chunks.push(e.data)
|
|
}
|
|
this.mediaRecorder.onerror = (ev) => {
|
|
console.error('[call-local-recorder] MediaRecorder 错误', ev)
|
|
}
|
|
|
|
const tick = () => {
|
|
if (!this.ctx || !this.canvas || !this.container) return
|
|
this.wireIncomingAudio()
|
|
const vids = resolveRecordingVideos(this.container)
|
|
drawVideosGrid(this.ctx, vids, this.canvas.width, this.canvas.height)
|
|
this.canvasVideoTrack?.requestFrame?.()
|
|
this.rafId = requestAnimationFrame(tick)
|
|
}
|
|
this.rafId = requestAnimationFrame(tick)
|
|
|
|
try {
|
|
this.mediaRecorder.start(250)
|
|
} catch {
|
|
this.reset()
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private buildStreamWithoutMixer(
|
|
videoTracks: MediaStreamTrack[],
|
|
videos: HTMLVideoElement[]
|
|
): MediaStream {
|
|
const seen = new Set<string>()
|
|
const audio: MediaStreamTrack[] = []
|
|
const push = (tracks: MediaStreamTrack[]) => {
|
|
for (const t of tracks) {
|
|
if (seen.has(t.id)) continue
|
|
seen.add(t.id)
|
|
audio.push(t)
|
|
}
|
|
}
|
|
push(collectAudioTracksFromVideos(videos))
|
|
push(collectAudioTracksFromCaptureStream(videos))
|
|
push(collectAudioTracksFromAudioElements(this.container))
|
|
return new MediaStream([...videoTracks, ...audio])
|
|
}
|
|
|
|
async stop(): Promise<Blob | null> {
|
|
this.beginStopNow()
|
|
const p = this.stopResultPromise
|
|
if (!p) return null
|
|
return p
|
|
}
|
|
}
|