新增功能

This commit is contained in:
Your Name
2026-03-24 16:32:56 +08:00
parent 250d173c2f
commit 9160c36735
248 changed files with 3063 additions and 250 deletions
+480
View File
@@ -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
}
}