diff --git a/scripts/video-track-probe.js b/scripts/video-track-probe.js new file mode 100644 index 0000000..4fff9ee --- /dev/null +++ b/scripts/video-track-probe.js @@ -0,0 +1,626 @@ +// ==UserScript== +// @name 12345 Video Track Probe +// @namespace http://tampermonkey.net/ +// @version 1.0 +// @description Probe page/WebRTC video input/output tracks and show active RTP stats. +// @author You +// @match *://yhy-yw.22sj.cn:*/* +// @grant none +// @run-at document-start +// ==/UserScript== + +(function () { + 'use strict'; + + const originalGetUserMedia = navigator.mediaDevices?.getUserMedia?.bind(navigator.mediaDevices); + const originalAddEventListener = RTCPeerConnection.prototype.addEventListener; + const originalAddTrack = RTCPeerConnection.prototype.addTrack; + const originalAddTransceiver = RTCPeerConnection.prototype.addTransceiver; + const originalSetRemoteDescription = RTCPeerConnection.prototype.setRemoteDescription; + const mediaSrcObjectDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'srcObject'); + + const candidates = new Map(); + const senderCandidates = new Map(); + const receivers = new Set(); + const senders = new Set(); + const localCaptureTrackIds = new Set(); + const inboundStatMemory = new Map(); + const outboundStatMemory = new Map(); + + let selectedTrackId = null; + let selectedSenderId = null; + let lastPanelRenderAt = 0; + + function nowText() { + return new Date().toLocaleTimeString([], { hour12: false }); + } + + function isLiveVideoTrack(track) { + return track?.kind === 'video' && track.readyState === 'live'; + } + + function shortId(id) { + if (!id) return 'none'; + if (id.length <= 18) return id; + return `${id.slice(0, 8)}...${id.slice(-6)}`; + } + + function trackSettings(track) { + try { + return track?.getSettings?.() || {}; + } catch { + return {}; + } + } + + function log(message) { + console.log(`[Video Track Probe] ${message}`); + const box = document.getElementById('video-track-probe-log'); + if (!box) return; + const row = document.createElement('div'); + row.textContent = `[${nowText()}] ${message}`; + box.appendChild(row); + while (box.children.length > 80) box.removeChild(box.firstChild); + box.scrollTop = box.scrollHeight; + } + + function ensureCandidate(track, source) { + if (!isLiveVideoTrack(track)) return null; + let candidate = candidates.get(track.id); + if (!candidate) { + candidate = { + track, + receiver: null, + sources: new Set(), + elements: new Set(), + isLocalCapture: localCaptureTrackIds.has(track.id), + firstSeenAt: Date.now(), + lastSeenAt: Date.now(), + lastPacketAt: 0, + bytesReceived: 0, + packetsReceived: 0, + bytesDelta: 0, + packetsDelta: 0, + framesReceived: 0, + framesDecoded: 0, + framesDropped: 0, + framesDelta: 0, + frameWidth: 0, + frameHeight: 0, + framesPerSecond: 0, + score: 0, + }; + candidates.set(track.id, candidate); + log(`found video track ${track.id} (${source})`); + } + candidate.track = track; + candidate.sources.add(source); + candidate.lastSeenAt = Date.now(); + candidate.isLocalCapture = localCaptureTrackIds.has(track.id); + return candidate; + } + + function ensureSenderCandidate(sender, track, source) { + if (!sender) return null; + const senderTrack = track || sender.track; + if (senderTrack && senderTrack.kind !== 'video') return null; + + const id = sender.__videoTrackProbeId || `sender-${senderCandidates.size + 1}`; + sender.__videoTrackProbeId = id; + + let candidate = senderCandidates.get(id); + if (!candidate) { + candidate = { + id, + sender, + track: senderTrack || null, + sources: new Set(), + isLocalCapture: Boolean(senderTrack && localCaptureTrackIds.has(senderTrack.id)), + firstSeenAt: Date.now(), + lastSeenAt: Date.now(), + bytesSent: 0, + packetsSent: 0, + bytesDelta: 0, + packetsDelta: 0, + framesEncoded: 0, + framesSent: 0, + framesDelta: 0, + frameWidth: 0, + frameHeight: 0, + score: 0, + }; + senderCandidates.set(id, candidate); + log(`found video sender ${id} (${source}) track=${senderTrack?.id || 'null'}`); + } + + candidate.sender = sender; + candidate.track = senderTrack || sender.track || candidate.track; + candidate.sources.add(source); + candidate.lastSeenAt = Date.now(); + candidate.isLocalCapture = Boolean(candidate.track && localCaptureTrackIds.has(candidate.track.id)); + senders.add(sender); + wrapSender(sender); + return candidate; + } + + function wrapSender(sender) { + if (!sender || sender.__videoTrackProbeWrapped) return; + const originalReplaceTrack = sender.replaceTrack?.bind(sender); + if (!originalReplaceTrack) return; + + sender.replaceTrack = async function (track) { + const result = await originalReplaceTrack(track); + if (!track || track.kind === 'video') { + const candidate = ensureSenderCandidate(sender, track, 'replaceTrack'); + if (candidate) { + candidate.track = track; + log(`sender ${candidate.id} replaceTrack -> ${track?.id || 'null'}`); + } + } + return result; + }; + sender.__videoTrackProbeWrapped = true; + } + + function rememberReceiver(receiver, source) { + if (!receiver?.track || !isLiveVideoTrack(receiver.track)) return; + receivers.add(receiver); + const candidate = ensureCandidate(receiver.track, source); + if (candidate) candidate.receiver = receiver; + } + + function rememberStream(stream, source, element) { + if (!stream || !(stream instanceof MediaStream)) return; + for (const track of stream.getVideoTracks()) { + const candidate = ensureCandidate(track, source); + if (candidate && element) candidate.elements.add(element); + } + } + + function scanMediaElements() { + document.querySelectorAll('video').forEach((element, index) => { + rememberStream(element.srcObject, `element-${index}:video`, element); + }); + } + + function applyInboundVideoStat(stat, candidate) { + const prev = inboundStatMemory.get(stat.id); + const bytes = stat.bytesReceived || 0; + const packets = stat.packetsReceived || 0; + const framesReceived = stat.framesReceived || 0; + const framesDecoded = stat.framesDecoded || 0; + const framesDropped = stat.framesDropped || 0; + + candidate.bytesDelta = prev ? Math.max(0, bytes - prev.bytes) : 0; + candidate.packetsDelta = prev ? Math.max(0, packets - prev.packets) : 0; + candidate.framesDelta = prev ? Math.max(0, framesDecoded - prev.framesDecoded) : 0; + candidate.bytesReceived = bytes; + candidate.packetsReceived = packets; + candidate.framesReceived = framesReceived; + candidate.framesDecoded = framesDecoded; + candidate.framesDropped = framesDropped; + candidate.frameWidth = stat.frameWidth || candidate.frameWidth; + candidate.frameHeight = stat.frameHeight || candidate.frameHeight; + candidate.framesPerSecond = stat.framesPerSecond || candidate.framesPerSecond; + + if (candidate.bytesDelta > 0 || candidate.packetsDelta > 0 || candidate.framesDelta > 0) { + candidate.lastPacketAt = Date.now(); + } + + inboundStatMemory.set(stat.id, { bytes, packets, framesDecoded }); + } + + function applyOutboundVideoStat(stat, candidate) { + const prev = outboundStatMemory.get(stat.id); + const bytes = stat.bytesSent || 0; + const packets = stat.packetsSent || 0; + const framesEncoded = stat.framesEncoded || 0; + const framesSent = stat.framesSent || 0; + + candidate.bytesDelta = prev ? Math.max(0, bytes - prev.bytes) : 0; + candidate.packetsDelta = prev ? Math.max(0, packets - prev.packets) : 0; + candidate.framesDelta = prev ? Math.max(0, framesSent - prev.framesSent) : 0; + candidate.bytesSent = bytes; + candidate.packetsSent = packets; + candidate.framesEncoded = framesEncoded; + candidate.framesSent = framesSent; + candidate.frameWidth = stat.frameWidth || candidate.frameWidth; + candidate.frameHeight = stat.frameHeight || candidate.frameHeight; + + outboundStatMemory.set(stat.id, { bytes, packets, framesSent }); + } + + async function refreshStats() { + for (const receiver of Array.from(receivers)) { + if (!receiver.track || receiver.track.readyState !== 'live') { + receivers.delete(receiver); + continue; + } + const candidate = ensureCandidate(receiver.track, 'receiver-stats'); + if (!candidate || !receiver.getStats) continue; + candidate.receiver = receiver; + + try { + const report = await receiver.getStats(); + report.forEach((stat) => { + const kind = stat.kind || stat.mediaType; + if (stat.type !== 'inbound-rtp' || kind !== 'video') return; + applyInboundVideoStat(stat, candidate); + }); + } catch (error) { + candidate.sources.add(`stats-error:${error.message || error}`); + } + } + + chooseActiveTrack(); + await refreshSenderStats(); + chooseActiveSender(); + } + + async function refreshSenderStats() { + for (const sender of Array.from(senders)) { + if (!sender) { + senders.delete(sender); + continue; + } + + const candidate = ensureSenderCandidate(sender, sender.track, 'sender-stats'); + if (!candidate || !sender.getStats) continue; + + try { + const report = await sender.getStats(); + report.forEach((stat) => { + const kind = stat.kind || stat.mediaType; + if (stat.type !== 'outbound-rtp' || kind !== 'video') return; + applyOutboundVideoStat(stat, candidate); + }); + } catch (error) { + candidate.sources.add(`sender-stats-error:${error.message || error}`); + } + } + } + + function scoreCandidate(candidate) { + if (!candidate || !isLiveVideoTrack(candidate.track)) return -1; + let score = 0; + if (!candidate.track.muted) score += 1000; + if (candidate.receiver) score += 300; + if (candidate.bytesDelta > 0) score += 4000 + Math.min(1500, candidate.bytesDelta); + if (candidate.packetsDelta > 0) score += 2000 + Math.min(800, candidate.packetsDelta * 8); + if (candidate.framesDelta > 0) score += 3000 + Math.min(1200, candidate.framesDelta * 40); + if (Date.now() - candidate.lastPacketAt < 3000) score += 1200; + if (candidate.frameWidth > 0 && candidate.frameHeight > 0) score += 400; + if (candidate.elements.size > 0) score += 200; + if (candidate.isLocalCapture) score -= 2500; + return score; + } + + function chooseActiveTrack() { + let best = null; + for (const candidate of candidates.values()) { + candidate.score = scoreCandidate(candidate); + if (!best || candidate.score > best.score) best = candidate; + } + if (!best || best.score < 0) return; + if (best.track.id !== selectedTrackId) { + selectedTrackId = best.track.id; + log(`active video looks like ${best.track.id}, score=${Math.round(best.score)}, ${best.frameWidth}x${best.frameHeight}`); + } + } + + function scoreSenderCandidate(candidate) { + if (!candidate) return -1; + let score = 0; + if (candidate.track?.readyState === 'live') score += 1000; + if (candidate.track && !candidate.track.muted) score += 400; + if (candidate.bytesSent > 0) score += 300; + if (candidate.bytesDelta > 0) score += 4500 + Math.min(1800, candidate.bytesDelta); + if (candidate.packetsDelta > 0) score += 1800 + Math.min(900, candidate.packetsDelta * 10); + if (candidate.framesDelta > 0) score += 2200 + Math.min(1000, candidate.framesDelta * 30); + if (candidate.frameWidth > 0 && candidate.frameHeight > 0) score += 300; + if (candidate.isLocalCapture) score += 2500; + if (candidate.sources.has('pc-addTrack')) score += 1500; + return score; + } + + function chooseActiveSender() { + let best = null; + for (const candidate of senderCandidates.values()) { + candidate.score = scoreSenderCandidate(candidate); + if (!best || candidate.score > best.score) best = candidate; + } + if (!best || best.score < 0) return; + if (best.id !== selectedSenderId) { + selectedSenderId = best.id; + log(`active output sender looks like ${best.id}, track=${best.track?.id || 'null'}, score=${Math.round(best.score)}`); + } + } + + function dumpInputs() { + return Array.from(candidates.values()) + .sort((a, b) => b.score - a.score) + .map((candidate) => { + const settings = trackSettings(candidate.track); + return { + selected: candidate.track.id === selectedTrackId, + id: candidate.track.id, + label: candidate.track.label, + enabled: candidate.track.enabled, + muted: candidate.track.muted, + readyState: candidate.track.readyState, + isLocalCapture: candidate.isLocalCapture, + hasReceiver: Boolean(candidate.receiver), + bytesReceived: candidate.bytesReceived, + packetsReceived: candidate.packetsReceived, + bytesDelta: candidate.bytesDelta, + packetsDelta: candidate.packetsDelta, + framesReceived: candidate.framesReceived, + framesDecoded: candidate.framesDecoded, + framesDropped: candidate.framesDropped, + framesDelta: candidate.framesDelta, + frameWidth: candidate.frameWidth || settings.width || 0, + frameHeight: candidate.frameHeight || settings.height || 0, + framesPerSecond: candidate.framesPerSecond || settings.frameRate || 0, + score: Math.round(candidate.score), + sources: Array.from(candidate.sources), + elements: Array.from(candidate.elements).map((element) => ({ + tag: element.tagName, + paused: element.paused, + muted: element.muted, + videoWidth: element.videoWidth, + videoHeight: element.videoHeight, + })), + }; + }); + } + + function dumpOutputs() { + return Array.from(senderCandidates.values()) + .sort((a, b) => b.score - a.score) + .map((candidate) => { + const settings = trackSettings(candidate.track); + return { + selected: candidate.id === selectedSenderId, + id: candidate.id, + trackId: candidate.track?.id || null, + trackLabel: candidate.track?.label || null, + trackEnabled: candidate.track?.enabled ?? null, + trackMuted: candidate.track?.muted ?? null, + trackReadyState: candidate.track?.readyState || null, + isLocalCapture: candidate.isLocalCapture, + bytesSent: candidate.bytesSent, + packetsSent: candidate.packetsSent, + bytesDelta: candidate.bytesDelta, + packetsDelta: candidate.packetsDelta, + framesEncoded: candidate.framesEncoded, + framesSent: candidate.framesSent, + framesDelta: candidate.framesDelta, + frameWidth: candidate.frameWidth || settings.width || 0, + frameHeight: candidate.frameHeight || settings.height || 0, + score: Math.round(candidate.score), + sources: Array.from(candidate.sources), + }; + }); + } + + function dump() { + return { + selectedInputTrackId: selectedTrackId, + selectedOutputSenderId: selectedSenderId, + inputs: dumpInputs(), + outputs: dumpOutputs(), + }; + } + + function activityPct(row) { + return Math.min(100, Math.max( + row.framesDelta * 8, + row.bytesDelta / 40, + row.packetsDelta * 2 + )); + } + + function renderPanel() { + if (!document.body) return; + let panel = document.getElementById('video-track-probe-panel'); + if (!panel) { + panel = document.createElement('div'); + panel.id = 'video-track-probe-panel'; + panel.style.cssText = [ + 'position:fixed', + 'top:20px', + 'left:20px', + 'width:480px', + 'max-height:70vh', + 'z-index:999998', + 'box-sizing:border-box', + 'padding:12px', + 'border-radius:8px', + 'background:rgba(3,7,18,.96)', + 'color:#e5e7eb', + 'font:12px ui-monospace,Menlo,monospace', + 'box-shadow:0 12px 32px rgba(0,0,0,.35)', + 'overflow:auto', + ].join(';'); + panel.innerHTML = ` +