194 lines
6.3 KiB
TypeScript
194 lines
6.3 KiB
TypeScript
/** 视频 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<string, HTMLVideoElement>()
|
||
const areaByKey = new Map<string, number>()
|
||
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<Blob | null> {
|
||
const preferRemote = options?.preferRemote !== false
|
||
const roots = [searchRoot, ...(options?.extraRoots || [])].filter((r): r is HTMLElement => !!r)
|
||
|
||
const videoSet = new Set<HTMLVideoElement>()
|
||
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)
|
||
})
|
||
}
|