Files
zyt/admin/src/utils/call-video-screenshot.ts
T
2026-04-01 09:46:25 +08:00

194 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 视频 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)
})
}