refactor(webrtc): extract HTML_PAGE const to src/webrtc/html_page.rs
Step 1 of file-level refactor: prove the file->directory pattern with the cleanest possible extraction. - src/webrtc.rs: 913 -> 741 LOC - New src/webrtc/html_page.rs: 170-line HTML test page as pub(super) const - Parent module re-exports via `mod html_page; use html_page::HTML_PAGE;` so all references in handle_signaling stay unchanged. Verification (all green): - cargo build / cargo build --release - cargo test (79 lib + 3 integration = 82 pass, 1 ignored — unchanged) - cargo clippy --all-targets -- -D warnings - cargo fmt --check - cargo check --bin vaapi_import_bench --bin sw_encode_bench - Test count in webrtc.rs: 18 (unchanged from baseline) Oracle audit note: HTML_PAGE had a single use site (handle_signaling L257-258) and zero #[cfg(test)] references, so the extraction is provably behavior- preserving.
This commit is contained in:
+2
-172
@@ -17,178 +17,8 @@ use str0m::{Candidate, Event, IceConnectionState, Input, Output, Rtc, RtcConfig}
|
|||||||
/// bursts. See issue #23.
|
/// bursts. See issue #23.
|
||||||
const FORCED_KEYFRAME_MIN_INTERVAL: Duration = Duration::from_secs(1);
|
const FORCED_KEYFRAME_MIN_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
// ── 嵌入式 HTML 测试页面 ──────────────────────────────────────────────────
|
mod html_page;
|
||||||
|
use html_page::HTML_PAGE;
|
||||||
const HTML_PAGE: &str = r#"<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head><title>wl-webrtc P0</title>
|
|
||||||
<style>body{background:#000;color:#fff;font-family:monospace;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
||||||
video{max-width:90vw;max-height:80vh;border:1px solid #333}
|
|
||||||
#status{margin:12px;font-size:14px;color:#aaa}
|
|
||||||
#debug{position:fixed;bottom:8px;left:8px;font-size:11px;color:#666;max-width:90vw;white-space:pre-wrap}
|
|
||||||
#stats-panel{position:fixed;top:8px;right:8px;background:rgba(0,0,0,0.7);color:#0f0;font:11px monospace;padding:6px 10px;border-radius:4px;z-index:100;pointer-events:none;max-width:90vw;white-space:pre;line-height:1.5}
|
|
||||||
</style></head>
|
|
||||||
<body>
|
|
||||||
<div id="status">Connecting...</div>
|
|
||||||
<video id="video" autoplay playsinline muted></video>
|
|
||||||
<pre id="debug"></pre>
|
|
||||||
<div id="stats-panel"></div>
|
|
||||||
<script>
|
|
||||||
const status = document.getElementById('status');
|
|
||||||
const video = document.getElementById('video');
|
|
||||||
const debug = document.getElementById('debug');
|
|
||||||
let pc = null;
|
|
||||||
|
|
||||||
const log = msg => { debug.textContent += msg + '\n'; console.log(msg); };
|
|
||||||
|
|
||||||
function preferH264(sdp) {
|
|
||||||
const lines = sdp.split('\r\n');
|
|
||||||
const h264Pts = lines
|
|
||||||
.filter(line => line.startsWith('a=rtpmap:') && line.toUpperCase().includes('H264/90000'))
|
|
||||||
.map(line => line.match(/^a=rtpmap:(\d+)/)?.[1])
|
|
||||||
.filter(Boolean);
|
|
||||||
if (h264Pts.length === 0) return sdp;
|
|
||||||
return lines.map(line => {
|
|
||||||
if (!line.startsWith('m=video ')) return line;
|
|
||||||
const parts = line.split(' ');
|
|
||||||
const header = parts.slice(0, 3);
|
|
||||||
const pts = parts.slice(3);
|
|
||||||
const preferred = h264Pts.filter(pt => pts.includes(pt));
|
|
||||||
const rest = pts.filter(pt => !preferred.includes(pt));
|
|
||||||
return [...header, ...preferred, ...rest].join(' ');
|
|
||||||
}).join('\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function installStatsLogger(peer) {
|
|
||||||
const panel = document.getElementById('stats-panel');
|
|
||||||
let prev = null;
|
|
||||||
const intervalSecs = 1;
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
if (peer !== pc) return;
|
|
||||||
peer.getStats().then(stats => {
|
|
||||||
let rtp = null, rtt = null, codecStr = '';
|
|
||||||
let freezeCount = null, totalFreezesDuration = null;
|
|
||||||
|
|
||||||
stats.forEach(report => {
|
|
||||||
if (report.type === 'inbound-rtp' && report.kind === 'video') rtp = report;
|
|
||||||
if (report.type === 'codec' && report.mimeType && report.mimeType.includes('H264'))
|
|
||||||
codecStr = report.mimeType + ' ' + (report.payloadType || '');
|
|
||||||
// candidate-pair: feature-detect 'selected' property
|
|
||||||
if (report.type === 'candidate-pair') {
|
|
||||||
const isSel = ('selected' in report) ? report.selected : report.state === 'succeeded';
|
|
||||||
if (isSel && typeof report.currentRoundTripTime === 'number') rtt = report.currentRoundTripTime;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Freeze stats (feature-detect)
|
|
||||||
if (rtp && typeof rtp.freezeCount !== 'undefined') {
|
|
||||||
freezeCount = rtp.freezeCount;
|
|
||||||
totalFreezesDuration = rtp.totalFreezesDuration;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rtp) return;
|
|
||||||
|
|
||||||
const cur = {
|
|
||||||
framesDecoded: rtp.framesDecoded || 0,
|
|
||||||
framesDropped: rtp.framesDropped || 0,
|
|
||||||
framesPerSecond: rtp.framesPerSecond || 0,
|
|
||||||
packetsLost: rtp.packetsLost || 0,
|
|
||||||
jitter: rtp.jitter || 0,
|
|
||||||
bytesReceived: rtp.bytesReceived || 0,
|
|
||||||
totalDecodeTime: rtp.totalDecodeTime || 0,
|
|
||||||
jitterBufferDelay: rtp.jitterBufferDelay || 0,
|
|
||||||
jitterBufferEmittedCount: rtp.jitterBufferEmittedCount || 0,
|
|
||||||
freezeCount: freezeCount,
|
|
||||||
totalFreezesDuration: totalFreezesDuration,
|
|
||||||
rtt: rtt,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Raw log to debug element (backward compat)
|
|
||||||
log('RTP-in: decoded=' + cur.framesDecoded + ' lost=' + cur.packetsLost +
|
|
||||||
' bytes=' + cur.bytesReceived + ' fps=' + cur.framesPerSecond +
|
|
||||||
(codecStr ? ' codec=' + codecStr : ''));
|
|
||||||
|
|
||||||
if (!prev) { prev = cur; return; }
|
|
||||||
|
|
||||||
// Compute deltas
|
|
||||||
const dFrames = cur.framesDecoded - prev.framesDecoded;
|
|
||||||
const dDropped = cur.framesDropped - prev.framesDropped;
|
|
||||||
const dLost = cur.packetsLost - prev.packetsLost;
|
|
||||||
const dBytes = cur.bytesReceived - prev.bytesReceived;
|
|
||||||
const dDecodeTime = cur.totalDecodeTime - prev.totalDecodeTime;
|
|
||||||
const dJitterBufDelay = cur.jitterBufferDelay - prev.jitterBufferDelay;
|
|
||||||
const dJitterBufCount = cur.jitterBufferEmittedCount - prev.jitterBufferEmittedCount;
|
|
||||||
const kbps = Math.round(dBytes * 8 / intervalSecs / 1000);
|
|
||||||
const decodeMs = dFrames > 0 ? (dDecodeTime / dFrames * 1000).toFixed(1) : '—';
|
|
||||||
const jitterBufMs = dJitterBufCount > 0 ? (dJitterBufDelay / dJitterBufCount * 1000).toFixed(1) : '—';
|
|
||||||
const jitterMs = (cur.jitter * 1000).toFixed(1);
|
|
||||||
const rttMs = cur.rtt !== null ? (cur.rtt * 1000).toFixed(1) : null;
|
|
||||||
|
|
||||||
let line = 'FPS:' + cur.framesPerSecond +
|
|
||||||
' Decoded:' + cur.framesDecoded + '(+' + dFrames + ')' +
|
|
||||||
' Dropped:' + cur.framesDropped + (dDropped > 0 ? '(+' + dDropped + ')' : '') +
|
|
||||||
' Lost:' + dLost +
|
|
||||||
' Jitter:' + jitterMs + 'ms' +
|
|
||||||
(rttMs !== null ? ' RTT:' + rttMs + 'ms' : '') +
|
|
||||||
' Decode:' + decodeMs + 'ms' +
|
|
||||||
' JBuf:' + jitterBufMs + 'ms';
|
|
||||||
|
|
||||||
if (freezeCount !== null) {
|
|
||||||
const dFreeze = cur.freezeCount - (prev.freezeCount || 0);
|
|
||||||
if (cur.freezeCount > 0 || dFreeze > 0)
|
|
||||||
line += ' Freeze:' + cur.freezeCount + '(+' + dFreeze + ')';
|
|
||||||
}
|
|
||||||
|
|
||||||
line += ' ' + kbps + 'kbps';
|
|
||||||
|
|
||||||
panel.textContent = line;
|
|
||||||
prev = cur;
|
|
||||||
}).catch(() => {});
|
|
||||||
}, intervalSecs * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
if (pc) pc.close();
|
|
||||||
pc = new RTCPeerConnection();
|
|
||||||
const peer = pc;
|
|
||||||
|
|
||||||
peer.ontrack = e => {
|
|
||||||
log('ontrack: streams=' + e.streams.length + ' kind=' + e.track.kind);
|
|
||||||
video.srcObject = e.streams[0];
|
|
||||||
status.textContent = 'Track received';
|
|
||||||
};
|
|
||||||
peer.oniceconnectionstatechange = () => {
|
|
||||||
log('ICE: ' + peer.iceConnectionState);
|
|
||||||
status.textContent = 'ICE: ' + peer.iceConnectionState;
|
|
||||||
};
|
|
||||||
|
|
||||||
peer.addTransceiver('video', { direction: 'recvonly' });
|
|
||||||
installStatsLogger(peer);
|
|
||||||
|
|
||||||
peer.createOffer().then(offer => {
|
|
||||||
offer.sdp = preferH264(offer.sdp);
|
|
||||||
return peer.setLocalDescription(offer);
|
|
||||||
})
|
|
||||||
.then(() => new Promise(resolve => {
|
|
||||||
if (peer.iceGatheringState === 'complete') resolve();
|
|
||||||
else peer.onicegatheringstatechange = () => { if (peer.iceGatheringState === 'complete') resolve(); };
|
|
||||||
}))
|
|
||||||
.then(() => fetch('/sdp', { method: 'POST', body: JSON.stringify(peer.localDescription) }))
|
|
||||||
.then(r => { if (!r.ok) throw new Error('SDP exchange failed: ' + r.status); return r.json(); })
|
|
||||||
.then(answer => { if (answer.error) throw new Error(answer.error); return peer.setRemoteDescription(answer); })
|
|
||||||
.then(() => log('SDP answer set'))
|
|
||||||
.catch(e => {
|
|
||||||
status.textContent = 'Error: ' + e.message;
|
|
||||||
log('ERROR: ' + e.message + ' — retrying in 2s...');
|
|
||||||
console.error(e);
|
|
||||||
setTimeout(connect, 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
connect();
|
|
||||||
</script>
|
|
||||||
</body></html>"#;
|
|
||||||
|
|
||||||
// ── WebRTC 状态 ───────────────────────────────────────────────────────────
|
// ── WebRTC 状态 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
pub(super) const HTML_PAGE: &str = r#"<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>wl-webrtc P0</title>
|
||||||
|
<style>body{background:#000;color:#fff;font-family:monospace;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||||
|
video{max-width:90vw;max-height:80vh;border:1px solid #333}
|
||||||
|
#status{margin:12px;font-size:14px;color:#aaa}
|
||||||
|
#debug{position:fixed;bottom:8px;left:8px;font-size:11px;color:#666;max-width:90vw;white-space:pre-wrap}
|
||||||
|
#stats-panel{position:fixed;top:8px;right:8px;background:rgba(0,0,0,0.7);color:#0f0;font:11px monospace;padding:6px 10px;border-radius:4px;z-index:100;pointer-events:none;max-width:90vw;white-space:pre;line-height:1.5}
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div id="status">Connecting...</div>
|
||||||
|
<video id="video" autoplay playsinline muted></video>
|
||||||
|
<pre id="debug"></pre>
|
||||||
|
<div id="stats-panel"></div>
|
||||||
|
<script>
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const video = document.getElementById('video');
|
||||||
|
const debug = document.getElementById('debug');
|
||||||
|
let pc = null;
|
||||||
|
|
||||||
|
const log = msg => { debug.textContent += msg + '\n'; console.log(msg); };
|
||||||
|
|
||||||
|
function preferH264(sdp) {
|
||||||
|
const lines = sdp.split('\r\n');
|
||||||
|
const h264Pts = lines
|
||||||
|
.filter(line => line.startsWith('a=rtpmap:') && line.toUpperCase().includes('H264/90000'))
|
||||||
|
.map(line => line.match(/^a=rtpmap:(\d+)/)?.[1])
|
||||||
|
.filter(Boolean);
|
||||||
|
if (h264Pts.length === 0) return sdp;
|
||||||
|
return lines.map(line => {
|
||||||
|
if (!line.startsWith('m=video ')) return line;
|
||||||
|
const parts = line.split(' ');
|
||||||
|
const header = parts.slice(0, 3);
|
||||||
|
const pts = parts.slice(3);
|
||||||
|
const preferred = h264Pts.filter(pt => pts.includes(pt));
|
||||||
|
const rest = pts.filter(pt => !preferred.includes(pt));
|
||||||
|
return [...header, ...preferred, ...rest].join(' ');
|
||||||
|
}).join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function installStatsLogger(peer) {
|
||||||
|
const panel = document.getElementById('stats-panel');
|
||||||
|
let prev = null;
|
||||||
|
const intervalSecs = 1;
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
if (peer !== pc) return;
|
||||||
|
peer.getStats().then(stats => {
|
||||||
|
let rtp = null, rtt = null, codecStr = '';
|
||||||
|
let freezeCount = null, totalFreezesDuration = null;
|
||||||
|
|
||||||
|
stats.forEach(report => {
|
||||||
|
if (report.type === 'inbound-rtp' && report.kind === 'video') rtp = report;
|
||||||
|
if (report.type === 'codec' && report.mimeType && report.mimeType.includes('H264'))
|
||||||
|
codecStr = report.mimeType + ' ' + (report.payloadType || '');
|
||||||
|
// candidate-pair: feature-detect 'selected' property
|
||||||
|
if (report.type === 'candidate-pair') {
|
||||||
|
const isSel = ('selected' in report) ? report.selected : report.state === 'succeeded';
|
||||||
|
if (isSel && typeof report.currentRoundTripTime === 'number') rtt = report.currentRoundTripTime;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Freeze stats (feature-detect)
|
||||||
|
if (rtp && typeof rtp.freezeCount !== 'undefined') {
|
||||||
|
freezeCount = rtp.freezeCount;
|
||||||
|
totalFreezesDuration = rtp.totalFreezesDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rtp) return;
|
||||||
|
|
||||||
|
const cur = {
|
||||||
|
framesDecoded: rtp.framesDecoded || 0,
|
||||||
|
framesDropped: rtp.framesDropped || 0,
|
||||||
|
framesPerSecond: rtp.framesPerSecond || 0,
|
||||||
|
packetsLost: rtp.packetsLost || 0,
|
||||||
|
jitter: rtp.jitter || 0,
|
||||||
|
bytesReceived: rtp.bytesReceived || 0,
|
||||||
|
totalDecodeTime: rtp.totalDecodeTime || 0,
|
||||||
|
jitterBufferDelay: rtp.jitterBufferDelay || 0,
|
||||||
|
jitterBufferEmittedCount: rtp.jitterBufferEmittedCount || 0,
|
||||||
|
freezeCount: freezeCount,
|
||||||
|
totalFreezesDuration: totalFreezesDuration,
|
||||||
|
rtt: rtt,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Raw log to debug element (backward compat)
|
||||||
|
log('RTP-in: decoded=' + cur.framesDecoded + ' lost=' + cur.packetsLost +
|
||||||
|
' bytes=' + cur.bytesReceived + ' fps=' + cur.framesPerSecond +
|
||||||
|
(codecStr ? ' codec=' + codecStr : ''));
|
||||||
|
|
||||||
|
if (!prev) { prev = cur; return; }
|
||||||
|
|
||||||
|
// Compute deltas
|
||||||
|
const dFrames = cur.framesDecoded - prev.framesDecoded;
|
||||||
|
const dDropped = cur.framesDropped - prev.framesDropped;
|
||||||
|
const dLost = cur.packetsLost - prev.packetsLost;
|
||||||
|
const dBytes = cur.bytesReceived - prev.bytesReceived;
|
||||||
|
const dDecodeTime = cur.totalDecodeTime - prev.totalDecodeTime;
|
||||||
|
const dJitterBufDelay = cur.jitterBufferDelay - prev.jitterBufferDelay;
|
||||||
|
const dJitterBufCount = cur.jitterBufferEmittedCount - prev.jitterBufferEmittedCount;
|
||||||
|
const kbps = Math.round(dBytes * 8 / intervalSecs / 1000);
|
||||||
|
const decodeMs = dFrames > 0 ? (dDecodeTime / dFrames * 1000).toFixed(1) : '—';
|
||||||
|
const jitterBufMs = dJitterBufCount > 0 ? (dJitterBufDelay / dJitterBufCount * 1000).toFixed(1) : '—';
|
||||||
|
const jitterMs = (cur.jitter * 1000).toFixed(1);
|
||||||
|
const rttMs = cur.rtt !== null ? (cur.rtt * 1000).toFixed(1) : null;
|
||||||
|
|
||||||
|
let line = 'FPS:' + cur.framesPerSecond +
|
||||||
|
' Decoded:' + cur.framesDecoded + '(+' + dFrames + ')' +
|
||||||
|
' Dropped:' + cur.framesDropped + (dDropped > 0 ? '(+' + dDropped + ')' : '') +
|
||||||
|
' Lost:' + dLost +
|
||||||
|
' Jitter:' + jitterMs + 'ms' +
|
||||||
|
(rttMs !== null ? ' RTT:' + rttMs + 'ms' : '') +
|
||||||
|
' Decode:' + decodeMs + 'ms' +
|
||||||
|
' JBuf:' + jitterBufMs + 'ms';
|
||||||
|
|
||||||
|
if (freezeCount !== null) {
|
||||||
|
const dFreeze = cur.freezeCount - (prev.freezeCount || 0);
|
||||||
|
if (cur.freezeCount > 0 || dFreeze > 0)
|
||||||
|
line += ' Freeze:' + cur.freezeCount + '(+' + dFreeze + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
line += ' ' + kbps + 'kbps';
|
||||||
|
|
||||||
|
panel.textContent = line;
|
||||||
|
prev = cur;
|
||||||
|
}).catch(() => {});
|
||||||
|
}, intervalSecs * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (pc) pc.close();
|
||||||
|
pc = new RTCPeerConnection();
|
||||||
|
const peer = pc;
|
||||||
|
|
||||||
|
peer.ontrack = e => {
|
||||||
|
log('ontrack: streams=' + e.streams.length + ' kind=' + e.track.kind);
|
||||||
|
video.srcObject = e.streams[0];
|
||||||
|
status.textContent = 'Track received';
|
||||||
|
};
|
||||||
|
peer.oniceconnectionstatechange = () => {
|
||||||
|
log('ICE: ' + peer.iceConnectionState);
|
||||||
|
status.textContent = 'ICE: ' + peer.iceConnectionState;
|
||||||
|
};
|
||||||
|
|
||||||
|
peer.addTransceiver('video', { direction: 'recvonly' });
|
||||||
|
installStatsLogger(peer);
|
||||||
|
|
||||||
|
peer.createOffer().then(offer => {
|
||||||
|
offer.sdp = preferH264(offer.sdp);
|
||||||
|
return peer.setLocalDescription(offer);
|
||||||
|
})
|
||||||
|
.then(() => new Promise(resolve => {
|
||||||
|
if (peer.iceGatheringState === 'complete') resolve();
|
||||||
|
else peer.onicegatheringstatechange = () => { if (peer.iceGatheringState === 'complete') resolve(); };
|
||||||
|
}))
|
||||||
|
.then(() => fetch('/sdp', { method: 'POST', body: JSON.stringify(peer.localDescription) }))
|
||||||
|
.then(r => { if (!r.ok) throw new Error('SDP exchange failed: ' + r.status); return r.json(); })
|
||||||
|
.then(answer => { if (answer.error) throw new Error(answer.error); return peer.setRemoteDescription(answer); })
|
||||||
|
.then(() => log('SDP answer set'))
|
||||||
|
.catch(e => {
|
||||||
|
status.textContent = 'Error: ' + e.message;
|
||||||
|
log('ERROR: ' + e.message + ' — retrying in 2s...');
|
||||||
|
console.error(e);
|
||||||
|
setTimeout(connect, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body></html>"#;
|
||||||
Reference in New Issue
Block a user