新增功能
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 跨组件同步诊单数据(如视频截屏写入舌苔照后刷新编辑抽屉)
|
||||
*/
|
||||
export const DIAGNOSIS_TONGUE_IMAGES_UPDATED = 'zyt:diagnosis-tongue-images-updated'
|
||||
|
||||
export type DiagnosisTongueImagesUpdatedDetail = {
|
||||
diagnosisId: number
|
||||
tongue_images: string[]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export type FriendlyParse = { main: string; sub?: string; tag?: string }
|
||||
|
||||
/** 业务时间:支持秒级时间戳或毫秒(字符串数字) */
|
||||
export function formatBizTime(t: unknown): string | undefined {
|
||||
if (t == null || t === '') return undefined
|
||||
const n =
|
||||
typeof t === 'string' && /^\d+$/.test(t.trim())
|
||||
? parseInt(t.trim(), 10)
|
||||
: Number(t)
|
||||
if (!Number.isFinite(n) || n <= 0) return undefined
|
||||
return n > 1e12
|
||||
? dayjs(n).format('YYYY-MM-DD HH:mm:ss')
|
||||
: dayjs.unix(n).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
export function parseRtcInner(inner: Record<string, unknown>): FriendlyParse {
|
||||
const callType = inner.call_type
|
||||
const callTypeLabel =
|
||||
callType === 2 ? '视频通话' : callType === 1 ? '语音通话' : '通话'
|
||||
const cmd = String(inner.cmd || '')
|
||||
const cmdMap: Record<string, string> = {
|
||||
hangup: '已结束',
|
||||
invite: '发起通话',
|
||||
linebusy: '对方忙线',
|
||||
cancel: '已取消',
|
||||
reject: '已拒绝',
|
||||
accept: '已接听',
|
||||
timeout: '无人接听'
|
||||
}
|
||||
const action = cmdMap[cmd] || (cmd ? `状态:${cmd}` : '')
|
||||
const main = action ? `${callTypeLabel} · ${action}` : callTypeLabel
|
||||
const parts: string[] = []
|
||||
if (inner.inviter) parts.push(`发起方 ${inner.inviter}`)
|
||||
if (inner.invitee) parts.push(`对方 ${inner.invitee}`)
|
||||
if (inner.groupID) parts.push(`群组 ${inner.groupID}`)
|
||||
return {
|
||||
main,
|
||||
sub: parts.length ? parts.join(',') : undefined,
|
||||
tag: '通话'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 IM 自定义 / 信令 JSON 解析为可读文案(医生进诊室、音视频通话等)。
|
||||
* 与后台 IM 漫游、小程序 TUIKit 约定字段一致。
|
||||
*/
|
||||
export function parseImBusinessPayload(raw: string): FriendlyParse | null {
|
||||
const s = raw.trim()
|
||||
if (!s.startsWith('{')) return null
|
||||
let o: Record<string, unknown>
|
||||
try {
|
||||
o = JSON.parse(s) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!('businessID' in o) && !('cmd' in o)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (o.businessID === 'doctor_entered_consult_room') {
|
||||
const t = formatBizTime(o.time)
|
||||
return { main: '医生已进入诊室', sub: t, tag: '诊室' }
|
||||
}
|
||||
|
||||
if (o.businessID === 'patient_opened_chat') {
|
||||
const parts: string[] = []
|
||||
if (o.patientName) parts.push(`患者:${String(o.patientName)}`)
|
||||
if (o.patientId != null && o.patientId !== '') parts.push(`患者 ID:${String(o.patientId)}`)
|
||||
if (o.doctorId != null && o.doctorId !== '') parts.push(`关联医生:${String(o.doctorId)}`)
|
||||
const t = formatBizTime(o.time)
|
||||
if (t) parts.push(t)
|
||||
return { main: '患者进入聊天', sub: parts.length ? parts.join(' · ') : undefined, tag: '诊室' }
|
||||
}
|
||||
|
||||
if (o.businessID === 'user_typing_status') {
|
||||
return { main: '对方正在输入…', tag: '状态' }
|
||||
}
|
||||
|
||||
if (o.businessID === 'consultation_complete') {
|
||||
return { main: '问诊已完成', sub: formatBizTime(o.time), tag: '诊室' }
|
||||
}
|
||||
|
||||
if (o.businessID === 1 || o.businessID === '1') {
|
||||
let inner: unknown = o.data
|
||||
if (typeof inner === 'string') {
|
||||
try {
|
||||
inner = JSON.parse(inner)
|
||||
} catch {
|
||||
return { main: '通话信令', sub: '内层数据解析失败', tag: '通话' }
|
||||
}
|
||||
}
|
||||
if (inner && typeof inner === 'object') {
|
||||
return parseRtcInner(inner as Record<string, unknown>)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (o.businessID === 'rtc_call' || o.cmd) {
|
||||
return parseRtcInner(o as Record<string, unknown>)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 判断 TIM 自定义消息 payload.data 是否表示视频通话结束/挂断/未接通,
|
||||
* 用于 PC 管理端在 IM 收信后立即 endCall → DeleteCloudRecording,不依赖小程序上报。
|
||||
*/
|
||||
|
||||
function parseInnerData(data: unknown): Record<string, unknown> | null {
|
||||
if (data == null) return null
|
||||
if (typeof data === 'object' && !Array.isArray(data)) return data as Record<string, unknown>
|
||||
if (typeof data !== 'string') return null
|
||||
const t = data.trim()
|
||||
if (!t.startsWith('{')) return null
|
||||
try {
|
||||
return JSON.parse(t) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function rtcCmdIndicatesHangup(cmd: string): boolean {
|
||||
const c = cmd.toLowerCase()
|
||||
return ['hangup', 'cancel', 'reject', 'timeout', 'linebusy', 'end'].includes(c)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param raw TIM 自定义消息外层 JSON 字符串(payload.data)
|
||||
*/
|
||||
export function imCustomDataIndicatesVideoHangup(raw: string): boolean {
|
||||
const s = raw.trim()
|
||||
if (!s) return false
|
||||
|
||||
// 部分信令不落标准 businessID,整段字符串匹配(如含 call_end / call_engine)
|
||||
if (/\bcall_end\b/i.test(s) && (/call_engine|call_record|rtc/i.test(s) || /"reason"\s*:\s*\d/.test(s))) {
|
||||
return true
|
||||
}
|
||||
if (/call_engine_srv\.[a-z_]*call[a-z_]*/i.test(s) && /hangup|end|leave|reject|cancel/i.test(s)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!s.startsWith('{')) return false
|
||||
|
||||
let o: Record<string, unknown>
|
||||
try {
|
||||
o = JSON.parse(s) as Record<string, unknown>
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
// 标准音视频 businessID: 1
|
||||
if (o.businessID === 1 || o.businessID === '1') {
|
||||
const inner = parseInnerData(o.data)
|
||||
if (inner) {
|
||||
const cmd = String(inner.cmd ?? '')
|
||||
if (rtcCmdIndicatesHangup(cmd)) return true
|
||||
}
|
||||
}
|
||||
|
||||
if (o.businessID === 'rtc_call' || typeof o.cmd === 'string') {
|
||||
if (rtcCmdIndicatesHangup(String(o.cmd ?? ''))) return true
|
||||
}
|
||||
|
||||
const nested = JSON.stringify(o)
|
||||
if (nested.includes('call_engine_srv') && /hangup|call_end|CALL_END|reject|cancel/i.test(nested)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user