Files
ai-video-fullstack/scripts/video-track-probe.js
Xin Wang 1e1b5d84d4 Add Video Track Probe script for enhanced video monitoring
- Introduce a new user script `video-track-probe.js` that probes WebRTC video input/output tracks and displays active RTP statistics.
- Implement functionality to manage video track candidates, including live video detection and performance metrics.
- Enhance logging features to provide real-time feedback on video track status and statistics.
- Integrate mechanisms for tracking video elements and their associated media streams, improving overall media management in the WebRTC environment.
2026-07-09 09:41:10 +08:00

627 lines
27 KiB
JavaScript

// ==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 = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<strong style="color:#f9a8d4;font:13px system-ui,sans-serif;">Video Track Probe</strong>
<button id="video-track-probe-copy" style="border:0;border-radius:5px;padding:5px 8px;background:#db2777;color:white;cursor:pointer;">copy dump</button>
</div>
<div id="video-track-probe-summary" style="margin-bottom:8px;color:#d1d5db;"></div>
<div style="color:#f9a8d4;margin:8px 0 4px;">Input receivers</div>
<div id="video-track-probe-list"></div>
<div style="color:#c084fc;margin:10px 0 4px;">Output senders</div>
<div id="video-track-probe-output-list"></div>
<div id="video-track-probe-log" style="margin-top:8px;height:90px;overflow:auto;border-top:1px solid #374151;padding-top:6px;color:#fbcfe8;"></div>
`;
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 `
<div style="border:1px solid ${row.selected ? '#ec4899' : '#374151'};border-radius:6px;padding:8px;margin-bottom:6px;">
<div style="display:flex;justify-content:space-between;gap:8px;color:${color};">
<span>${row.selected ? '*' : '-'} ${shortId(row.id)}</span>
<span>score=${row.score}</span>
</div>
<div style="height:6px;background:#374151;border-radius:999px;overflow:hidden;margin:6px 0;">
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,#f472b6,#fbbf24);"></div>
</div>
<div style="color:#cbd5e1;line-height:1.45;">
${res} fps=${Number(row.framesPerSecond || 0).toFixed(1)} muted=${row.muted} receiver=${row.hasReceiver} framesDelta=${row.framesDelta} bytesDelta=${row.bytesDelta}
</div>
<div style="color:#94a3b8;word-break:break-all;">${row.sources.join(', ')}</div>
</div>
`;
}).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 `
<div style="border:1px solid ${row.selected ? '#a855f7' : '#374151'};border-radius:6px;padding:8px;margin-bottom:6px;">
<div style="display:flex;justify-content:space-between;gap:8px;color:${color};">
<span>${row.selected ? '*' : '-'} ${row.id}</span>
<span>score=${row.score}</span>
</div>
<div style="height:6px;background:#374151;border-radius:999px;overflow:hidden;margin:6px 0;">
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,#60a5fa,#c084fc);"></div>
</div>
<div style="color:#cbd5e1;line-height:1.45;">
track=${shortId(row.trackId)} ${res} local=${row.isLocalCapture} framesDelta=${row.framesDelta} bytesDelta=${row.bytesDelta}
</div>
<div style="color:#94a3b8;word-break:break-all;">${row.sources.join(', ')}</div>
</div>
`;
}).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();
}
})();