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 = ` +
+ Video Track Probe + +
+
+
Input receivers
+
+
Output senders
+
+
+ `; + document.body.appendChild(panel); + document.getElementById('video-track-probe-copy').addEventListener('click', async () => { + const text = JSON.stringify(window.__videoTrackProbeDump(), null, 2); + await navigator.clipboard?.writeText(text).catch(() => {}); + console.log('[Video Track Probe dump]', text); + log('dump copied to clipboard and console'); + }); + } + + const rows = dumpInputs(); + const outputRows = dumpOutputs(); + const selected = rows.find((row) => row.selected); + const selectedOutput = outputRows.find((row) => row.selected); + document.getElementById('video-track-probe-summary').textContent = + `inputs=${rows.length}, selected=${selected ? selected.id : 'none'} | outputs=${outputRows.length}, selected=${selectedOutput ? selectedOutput.id : 'none'}, time=${nowText()}`; + + document.getElementById('video-track-probe-list').innerHTML = rows.map((row) => { + const pct = activityPct(row); + const color = row.selected ? '#f9a8d4' : '#9ca3af'; + const res = row.frameWidth && row.frameHeight ? `${row.frameWidth}x${row.frameHeight}` : 'unknown'; + return ` +
+
+ ${row.selected ? '*' : '-'} ${shortId(row.id)} + score=${row.score} +
+
+
+
+
+ ${res} fps=${Number(row.framesPerSecond || 0).toFixed(1)} muted=${row.muted} receiver=${row.hasReceiver} framesDelta=${row.framesDelta} bytesDelta=${row.bytesDelta} +
+
${row.sources.join(', ')}
+
+ `; + }).join(''); + + document.getElementById('video-track-probe-output-list').innerHTML = outputRows.map((row) => { + const pct = activityPct(row); + const color = row.selected ? '#c084fc' : '#9ca3af'; + const res = row.frameWidth && row.frameHeight ? `${row.frameWidth}x${row.frameHeight}` : 'unknown'; + return ` +
+
+ ${row.selected ? '*' : '-'} ${row.id} + score=${row.score} +
+
+
+
+
+ track=${shortId(row.trackId)} ${res} local=${row.isLocalCapture} framesDelta=${row.framesDelta} bytesDelta=${row.bytesDelta} +
+
${row.sources.join(', ')}
+
+ `; + }).join(''); + } + + function wrapTrackListener(listener) { + if (!listener || listener.__videoTrackProbeWrapped) return listener; + const wrapped = function (event) { + rememberReceiver(event?.receiver, 'pc-track'); + if (event?.track?.kind === 'video') ensureCandidate(event.track, 'pc-track'); + for (const stream of event?.streams || []) rememberStream(stream, 'pc-track-stream'); + if (typeof listener === 'function') return listener.apply(this, arguments); + if (listener?.handleEvent) return listener.handleEvent(event); + return undefined; + }; + wrapped.__videoTrackProbeWrapped = true; + return wrapped; + } + + RTCPeerConnection.prototype.addEventListener = function (type, listener, options) { + if (type === 'track') return originalAddEventListener.call(this, type, wrapTrackListener(listener), options); + return originalAddEventListener.call(this, type, listener, options); + }; + + RTCPeerConnection.prototype.addTrack = function (track, ...streams) { + const sender = originalAddTrack.call(this, track, ...streams); + if (track?.kind === 'video') ensureSenderCandidate(sender, track, 'pc-addTrack'); + return sender; + }; + + if (originalAddTransceiver) { + RTCPeerConnection.prototype.addTransceiver = function (trackOrKind, init) { + const transceiver = originalAddTransceiver.call(this, trackOrKind, init); + const kind = trackOrKind instanceof MediaStreamTrack ? trackOrKind.kind : trackOrKind; + if (kind === 'video') { + const track = trackOrKind instanceof MediaStreamTrack ? trackOrKind : transceiver.sender?.track; + ensureSenderCandidate(transceiver.sender, track, 'pc-addTransceiver'); + } + return transceiver; + }; + } + + try { + const descriptor = Object.getOwnPropertyDescriptor(RTCPeerConnection.prototype, 'ontrack'); + Object.defineProperty(RTCPeerConnection.prototype, 'ontrack', { + configurable: true, + enumerable: true, + get() { + return descriptor?.get ? descriptor.get.call(this) : this.__videoTrackProbeOnTrack || null; + }, + set(listener) { + const wrapped = wrapTrackListener(listener); + this.__videoTrackProbeOnTrack = wrapped; + if (descriptor?.set) descriptor.set.call(this, wrapped); + else this.addEventListener('track', wrapped); + }, + }); + } catch (error) { + console.warn('[Video Track Probe] failed to wrap ontrack', error); + } + + RTCPeerConnection.prototype.setRemoteDescription = function () { + const result = originalSetRemoteDescription.apply(this, arguments); + Promise.resolve(result).then(() => { + try { + for (const receiver of this.getReceivers?.() || []) rememberReceiver(receiver, 'receiver-after-srd'); + for (const sender of this.getSenders?.() || []) ensureSenderCandidate(sender, sender.track, 'sender-after-srd'); + } catch (error) { + console.warn('[Video Track Probe] failed to scan receivers', error); + } + }); + return result; + }; + + if (mediaSrcObjectDescriptor?.set) { + Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', { + configurable: true, + enumerable: mediaSrcObjectDescriptor.enumerable, + get() { + return mediaSrcObjectDescriptor.get.call(this); + }, + set(stream) { + rememberStream(stream, `${this.tagName.toLowerCase()}-srcObject`, this); + return mediaSrcObjectDescriptor.set.call(this, stream); + }, + }); + } + + if (originalGetUserMedia) { + navigator.mediaDevices.getUserMedia = async function (constraints) { + const stream = await originalGetUserMedia(constraints); + for (const track of stream.getVideoTracks()) { + localCaptureTrackIds.add(track.id); + const candidate = ensureCandidate(track, 'getUserMedia'); + if (candidate) candidate.isLocalCapture = true; + } + return stream; + }; + } + + window.__videoTrackProbeDump = dump; + + setInterval(scanMediaElements, 500); + setInterval(refreshStats, 250); + setInterval(() => { + if (Date.now() - lastPanelRenderAt < 500) return; + lastPanelRenderAt = Date.now(); + renderPanel(); + }, 250); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', renderPanel, { once: true }); + } else { + renderPanel(); + } +})();