/** 视频 track 是否在播(排除已结束轨道) */ function hasLiveVideoTrack(video: HTMLVideoElement): boolean { const so = video.srcObject if (!(so instanceof MediaStream)) return false return so.getVideoTracks().some((t) => t.readyState === 'live') } /** 页面上实际占位面积(主画面远大于本地小窗) */ function layoutArea(v: HTMLVideoElement): number { const r = v.getBoundingClientRect() return Math.max(0, r.width) * Math.max(0, r.height) } function videoPixelArea(v: HTMLVideoElement): number { const vw = v.videoWidth || v.clientWidth const vh = v.videoHeight || v.clientHeight return vw * vh } /** 同一路 MediaStream 可能挂多个 video,只保留占位最大的一路,避免重复逻辑与歧义 */ function dedupeVideosByStream(videos: HTMLVideoElement[]): HTMLVideoElement[] { const byKey = new Map() const areaByKey = new Map() for (const v of videos) { const so = v.srcObject if (!(so instanceof MediaStream)) continue const tracks = so.getVideoTracks() const key = (so.id && String(so.id)) || tracks .map((t) => t.id) .filter(Boolean) .join('|') || `${tracks.length}` const la = layoutArea(v) const prev = areaByKey.get(key) ?? 0 if (la >= prev) { byKey.set(key, v) areaByKey.set(key, la) } } if (byKey.size === 0) return videos return Array.from(byKey.values()) } /** * 1v1 常见:远端全屏 + 本地小窗。去掉占位明显偏小的一路(画中画),只留主画面候选。 */ function dropObviousPip(videos: HTMLVideoElement[]): HTMLVideoElement[] { if (videos.length < 2) return videos let maxL = 0 for (const v of videos) maxL = Math.max(maxL, layoutArea(v)) if (maxL <= 1) return videos const minL = Math.min(...videos.map((v) => layoutArea(v))) if (minL / maxL >= 0.25) return videos return videos.filter((v) => layoutArea(v) >= maxL * 0.25) } /** 本地预览常见:镜像(scaleX(-1)) */ function hasMirrorTransform(el: HTMLElement): boolean { let p: HTMLElement | null = el for (let i = 0; i < 12 && p; i++) { const t = getComputedStyle(p).transform if (t && t !== 'none') { if (/matrix\(\s*-1\s*,/.test(t) || /scaleX\(\s*-1/.test(t)) return true } p = p.parentElement } return false } /** * 根据 DOM 判断是否为「本方/本地」预览(腾讯云 TUICall / TRTC 常见 class 及通用命名) * 注意:不要用泛化的 "player",否则本地/远端容器类名都会命中,导致无法区分。 */ function isLikelyLocalPreviewVideo(video: HTMLVideoElement): boolean { const localHints = /local|self|pusher|mini[_-]?stream|preview[_-]?self|own[_-]?camera|tui-local|tencent-local|stream-local|publisher|send|pip|picture[_-]?in[_-]?picture/i const remoteHints = /remote|subscriber|peer|other|tui-remote|stream-remote|big-stream|play[_-]?stream|receiver|pull|substream/i let el: HTMLElement | null = video for (let depth = 0; depth < 20 && el; depth++) { const s = `${el.className || ''} ${el.id || ''} ${el.getAttribute('data-type') || ''}` if (remoteHints.test(s)) return false if (localHints.test(s)) return true el = el.parentElement } return false } /** * 从通话区域截取一帧 JPEG。 * 默认优先截取「对方/远端」画面:去重 MediaStream、去掉画中画小窗、排除本地预览与镜像节点。 */ export async function captureVideoFrameFromElement( searchRoot: HTMLElement | null, options?: { preferRemote?: boolean; extraRoots?: (HTMLElement | null)[] } ): Promise { const preferRemote = options?.preferRemote !== false const roots = [searchRoot, ...(options?.extraRoots || [])].filter((r): r is HTMLElement => !!r) const videoSet = new Set() for (const r of roots) { for (const v of r.querySelectorAll('video')) { if (v instanceof HTMLVideoElement) videoSet.add(v) } } if (videoSet.size === 0) { const wrap = document.querySelector('.chat-dialog-call-kit-wrapper') if (wrap) { for (const v of wrap.querySelectorAll('video')) { if (v instanceof HTMLVideoElement) videoSet.add(v) } } } if (videoSet.size === 0) { document.body.querySelectorAll('video').forEach((v) => { if (v instanceof HTMLVideoElement && hasLiveVideoTrack(v)) videoSet.add(v) }) } let candidates = Array.from(videoSet).filter( (v) => v.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && hasLiveVideoTrack(v) ) if (candidates.length === 0) { candidates = Array.from(videoSet).filter((v) => v.readyState >= 2) } if (candidates.length === 0) return null candidates = dedupeVideosByStream(candidates) let best: HTMLVideoElement | null = null let bestLayout = 0 let bestPixel = 0 if (preferRemote) { const scored = candidates.map((v) => ({ v, layout: layoutArea(v), pixel: videoPixelArea(v), localClass: isLikelyLocalPreviewVideo(v), mirror: hasMirrorTransform(v) })) let pool = scored.filter((x) => !x.localClass && !x.mirror) if (pool.length === 0) pool = scored.filter((x) => !x.localClass) if (pool.length === 0) pool = scored.filter((x) => !x.mirror) if (pool.length === 0) pool = scored let videos = pool.map((x) => x.v) videos = dropObviousPip(videos) pool = pool.filter((x) => videos.includes(x.v)) for (const x of pool) { if (x.layout > bestLayout || (x.layout === bestLayout && x.pixel > bestPixel)) { bestLayout = x.layout bestPixel = x.pixel best = x.v } } } else { for (const v of candidates) { const la = layoutArea(v) const pa = videoPixelArea(v) if (la > bestLayout || (la === bestLayout && pa > bestPixel)) { bestLayout = la bestPixel = pa best = v } } } if (!best) return null const vw = best.videoWidth || best.clientWidth const vh = best.videoHeight || best.clientHeight if (!vw || !vh) return null const canvas = document.createElement('canvas') canvas.width = vw canvas.height = vh const ctx = canvas.getContext('2d') if (!ctx) return null try { ctx.drawImage(best, 0, 0, vw, vh) } catch { return null } return new Promise((resolve) => { canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.92) }) }