新增功能
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* 浏览器端本地录制:将通话区域内多路 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user