新增功能

This commit is contained in:
Your Name
2026-04-01 09:46:25 +08:00
parent 099bc1dd22
commit a780356908
332 changed files with 7845 additions and 866 deletions
+172 -14
View File
@@ -1,20 +1,178 @@
/** 视频通话区域截取当前最大的一路 video 帧(WebRTC 画面 */
export async function captureVideoFrameFromElement(
container: HTMLElement
): Promise<Blob | null> {
const videos = Array.from(container.querySelectorAll('video')) as HTMLVideoElement[]
let best: HTMLVideoElement | null = null
let bestArea = 0
/** 视频 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) {
if (v.readyState < 2) continue
const vw = v.videoWidth || v.clientWidth
const vh = v.videoHeight || v.clientHeight
const area = vw * vh
if (area > bestArea) {
bestArea = area
best = v
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