mirror of
https://github.com/SrIzan10/vdo.ninja.git
synced 2026-05-01 11:05:24 +00:00
1072 lines
40 KiB
HTML
1072 lines
40 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>SDP Compression Tool</title>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
|
line-height: 1.6;
|
|
color: #333;
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
.container {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 20px;
|
|
}
|
|
textarea {
|
|
width: 100%;
|
|
height: 200px;
|
|
padding: 10px;
|
|
box-sizing: border-box;
|
|
border: 1px solid #ccc;
|
|
border-radius: 4px;
|
|
font-family: monospace;
|
|
margin-bottom: 10px;
|
|
}
|
|
button {
|
|
background-color: #4CAF50;
|
|
color: white;
|
|
padding: 10px 15px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
margin-right: 10px;
|
|
margin-bottom: 10px;
|
|
transition: background-color 0.3s;
|
|
}
|
|
button:hover {
|
|
background-color: #45a049;
|
|
}
|
|
.qrcode-container {
|
|
margin-top: 20px;
|
|
text-align: center;
|
|
}
|
|
.output {
|
|
margin-top: 20px;
|
|
width: 100%;
|
|
height: max(100px, calc(100vh - 400px));
|
|
overflow-y: auto;
|
|
background-color: #f5f5f5;
|
|
padding: 10px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
font-family: monospace;
|
|
white-space: pre-wrap;
|
|
}
|
|
h3 {
|
|
margin-top: 20px;
|
|
margin-bottom: 10px;
|
|
}
|
|
.stats {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 20px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.stat-box {
|
|
background-color: #f0f0f0;
|
|
padding: 15px;
|
|
border-radius: 4px;
|
|
flex: 1;
|
|
min-width: 200px;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
}
|
|
.input-group {
|
|
margin-bottom: 15px;
|
|
}
|
|
.tabs {
|
|
display: flex;
|
|
border-bottom: 1px solid #ddd;
|
|
margin-bottom: 20px;
|
|
}
|
|
.tab {
|
|
padding: 10px 20px;
|
|
cursor: pointer;
|
|
background-color: #f8f8f8;
|
|
border: 1px solid #ddd;
|
|
border-bottom: none;
|
|
margin-right: 5px;
|
|
border-radius: 4px 4px 0 0;
|
|
}
|
|
.tab.active {
|
|
background-color: white;
|
|
border-bottom: 1px solid white;
|
|
margin-bottom: -1px;
|
|
}
|
|
.tab-content {
|
|
display: none;
|
|
}
|
|
.tab-content.active {
|
|
display: block;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>SDP Compression Tool</h1>
|
|
|
|
<div class="tabs">
|
|
<div class="tab active" data-tab="compression">Compression</div>
|
|
<div class="tab" data-tab="testing">Testing</div>
|
|
<div class="tab" data-tab="ice">ICE Candidates</div>
|
|
</div>
|
|
|
|
<div id="compression" class="tab-content active">
|
|
<h2>SDP Compression/Decompression</h2>
|
|
|
|
<div class="container">
|
|
<div>
|
|
<h3>Input SDP</h3>
|
|
<textarea id="sdpInput" placeholder="Paste SDP here..."></textarea>
|
|
<button id="compressBtn">Compress</button>
|
|
<button id="compressQRBtn">Compress for QR</button>
|
|
<button id="generateSampleSDPBtn">Generate Sample SDP</button>
|
|
</div>
|
|
|
|
<div>
|
|
<h3>Compressed Output</h3>
|
|
<textarea id="compressedOutput" placeholder="Compressed SDP will appear here..."></textarea>
|
|
<button id="decompressBtn">Decompress</button>
|
|
<button id="decompressQRBtn">Decompress QR Format</button>
|
|
<button id="copyCompressedBtn">Copy</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats">
|
|
<div class="stat-box">
|
|
<strong>Original Size:</strong> <span id="originalSize">0</span> bytes
|
|
</div>
|
|
<div class="stat-box">
|
|
<strong>Compressed Size:</strong> <span id="compressedSize">0</span> bytes
|
|
</div>
|
|
<div class="stat-box">
|
|
<strong>Compression Ratio:</strong> <span id="compressionRatio">0</span>%
|
|
</div>
|
|
</div>
|
|
|
|
<div class="qrcode-container">
|
|
<h3>QR Code</h3>
|
|
<div id="qrcode"></div>
|
|
</div>
|
|
|
|
<h3>Decompressed Result</h3>
|
|
<textarea id="decompressedOutput" placeholder="Decompressed SDP will appear here..."></textarea>
|
|
</div>
|
|
|
|
<div id="testing" class="tab-content">
|
|
<h2>SDP Compression Testing</h2>
|
|
<button id="runTestsBtn">Run All Tests</button>
|
|
<button id="validateSDPBtn">Validate Current SDP</button>
|
|
<h3>Test Output</h3>
|
|
<div id="testOutput" class="output"></div>
|
|
</div>
|
|
|
|
<div id="ice" class="tab-content">
|
|
<h2>ICE Candidates Compression</h2>
|
|
<div class="input-group">
|
|
<h3>ICE Candidates (JSON Array)</h3>
|
|
<textarea id="iceInput" placeholder='[{"candidate":"candidate:0 1 UDP 2122260223 192.168.1.2 56789 typ host","sdpMid":"0","sdpMLineIndex":0}]'></textarea>
|
|
<button id="compressIceBtn">Compress ICE</button>
|
|
<button id="generateSampleIceBtn">Generate Sample ICE</button>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<h3>Compressed ICE</h3>
|
|
<textarea id="iceCompressedOutput" placeholder="Compressed ICE candidates will appear here..."></textarea>
|
|
<button id="decompressIceBtn">Decompress ICE</button>
|
|
<button id="copyCompressedIceBtn">Copy</button>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<h3>Decompressed ICE</h3>
|
|
<textarea id="iceDecompressedOutput" placeholder="Decompressed ICE candidates will appear here..."></textarea>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
|
|
function compressSDP(sdp) {
|
|
const iceUfrag = sdp.match(/a=ice-ufrag:([^\r\n]+)/)[1];
|
|
const icePwd = sdp.match(/a=ice-pwd:([^\r\n]+)/)[1];
|
|
const fingerprintMatch = sdp.match(/a=fingerprint:sha-256 ([^\r\n]+)/);
|
|
const fingerprint = fingerprintMatch[1].replace(/:/g, '');
|
|
const isOffer = sdp.match(/a=setup:actpass/) !== null;
|
|
const candidates = [];
|
|
const candidateRegex = /a=candidate:([^\r\n]+)/g;
|
|
let candidateMatch;
|
|
while ((candidateMatch = candidateRegex.exec(sdp)) !== null) {
|
|
const parts = candidateMatch[1].split(' ');
|
|
if (parts[4].includes(':')) continue;
|
|
const foundation = parts[0];
|
|
const component = parts[1];
|
|
const protocol = parts[2].toLowerCase() === 'udp' ? 'u' : 't';
|
|
const priority = parseInt(parts[3]);
|
|
const ip = parts[4];
|
|
const port = parseInt(parts[5]);
|
|
const type = parts[7];
|
|
let typeCode;
|
|
switch (type) {
|
|
case 'host':
|
|
typeCode = 'h';
|
|
break;
|
|
case 'srflx':
|
|
typeCode = 's';
|
|
break;
|
|
case 'relay':
|
|
typeCode = 'r';
|
|
break;
|
|
default:
|
|
typeCode = 'x';
|
|
}
|
|
let relatedIP = '';
|
|
let relatedPort = '';
|
|
const rAddrIndex = parts.indexOf('raddr');
|
|
if (rAddrIndex !== -1 && rAddrIndex + 1 < parts.length) {
|
|
relatedIP = parts[rAddrIndex + 1];
|
|
}
|
|
const rPortIndex = parts.indexOf('rport');
|
|
if (rPortIndex !== -1 && rPortIndex + 1 < parts.length) {
|
|
relatedPort = parts[rPortIndex + 1];
|
|
}
|
|
let candidateStr = `${foundation},${component},${protocol},${priorityToCompact(priority)},${ip},${port},${typeCode}`;
|
|
if (relatedIP && relatedPort) {
|
|
candidateStr += `,${relatedIP},${relatedPort}`;
|
|
}
|
|
candidates.push(candidateStr);
|
|
}
|
|
const compactFingerprint = hexToCompact(fingerprint);
|
|
let result = `C1|${isOffer ? 'o' : 'a'}|${iceUfrag}|${icePwd}|${compactFingerprint}`;
|
|
if (candidates.length > 0) {
|
|
result += `|${candidates.join('/')}`;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function decompressSDP(compressed) {
|
|
try {
|
|
const parts = compressed.split('|');
|
|
if (parts[0] !== 'C1') {
|
|
throw new Error('Unsupported compression version');
|
|
}
|
|
const isOffer = parts[1] === 'o';
|
|
const iceUfrag = parts[2];
|
|
const icePwd = parts[3];
|
|
const fingerprint = compactToHex(parts[4]);
|
|
// Format fingerprint correctly with colons every 2 characters
|
|
const formattedFingerprint = fingerprint.match(/.{2}/g).join(':').toUpperCase();
|
|
|
|
// Build the basic SDP structure
|
|
let sdpLines = [
|
|
'v=0',
|
|
'o=- ' + Math.floor(Date.now()) + ' 2 IN IP4 127.0.0.1',
|
|
's=-',
|
|
't=0 0',
|
|
'a=group:BUNDLE 0',
|
|
'a=extmap-allow-mixed',
|
|
'a=msid-semantic: WMS',
|
|
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
|
|
'c=IN IP4 0.0.0.0',
|
|
`a=ice-ufrag:${iceUfrag}`,
|
|
`a=ice-pwd:${icePwd}`,
|
|
'a=ice-options:trickle',
|
|
`a=fingerprint:sha-256 ${formattedFingerprint}`,
|
|
`a=setup:${isOffer ? 'actpass' : 'active'}`,
|
|
'a=mid:0',
|
|
'a=sctp-port:5000',
|
|
'a=max-message-size:262144'
|
|
];
|
|
|
|
// Process candidates
|
|
if (parts.length > 5 && parts[5]) {
|
|
const candidates = parts[5].split('/');
|
|
|
|
for (const candidate of candidates) {
|
|
const candParts = candidate.split(',');
|
|
if (candParts.length < 7) continue;
|
|
|
|
// Extract candidate parts
|
|
const foundation = candParts[0];
|
|
const component = candParts[1];
|
|
const protocol = candParts[2] === 'u' ? 'udp' : 'tcp'; // Use lowercase protocol
|
|
const priority = compactToPriority(candParts[3]);
|
|
const ip = candParts[4];
|
|
const port = candParts[5];
|
|
let type;
|
|
switch (candParts[6]) {
|
|
case 'h':
|
|
type = 'host';
|
|
break;
|
|
case 's':
|
|
type = 'srflx';
|
|
break;
|
|
case 'r':
|
|
type = 'relay';
|
|
break;
|
|
default:
|
|
type = 'unknown';
|
|
}
|
|
|
|
// Build candidate line
|
|
let candidateLine = `a=candidate:${foundation} ${component} ${protocol} ${priority} ${ip} ${port} typ ${type}`;
|
|
|
|
// Add raddr/rport if available for srflx and relay candidates
|
|
if (type === 'srflx' || type === 'relay') {
|
|
// If related IP and port are specified in compression, use them
|
|
if (candParts.length >= 9 && candParts[7]) {
|
|
candidateLine += ` raddr ${candParts[7]} rport ${candParts[8]}`;
|
|
} else {
|
|
// Otherwise add defaults for Chrome compatibility
|
|
candidateLine += ` raddr 0.0.0.0 rport 0`;
|
|
}
|
|
}
|
|
|
|
// Add TCP type if needed
|
|
if (protocol === 'tcp') {
|
|
candidateLine += ' tcptype active';
|
|
}
|
|
|
|
// Add generation and network-cost
|
|
candidateLine += ' generation 0 network-cost 999';
|
|
|
|
sdpLines.push(candidateLine);
|
|
}
|
|
}
|
|
|
|
// Join with proper line endings
|
|
return sdpLines.join('\r\n') + '\r\n';
|
|
} catch (e) {
|
|
console.error("Error in decompressSDP:", e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
|
|
function compressIP(ip) {
|
|
if (ip === '127.0.0.1') return 'L';
|
|
if (ip === '0.0.0.0') return 'Z';
|
|
if (ip.startsWith('192.168.')) return 'P' + ip.split('.').slice(2).join('');
|
|
if (ip.startsWith('10.')) return 'T' + ip.split('.').slice(1).join('');
|
|
if (ip.startsWith('172.')) return 'S' + ip.split('.').slice(1).join('');
|
|
const parts = ip.split('.');
|
|
let num = 0;
|
|
for (let i = 0; i < 4; i++) {
|
|
num = num * 256 + parseInt(parts[i]);
|
|
}
|
|
return num.toString(36).toUpperCase();
|
|
}
|
|
|
|
function decompressIP(compressed) {
|
|
if (compressed === 'L') return '127.0.0.1';
|
|
if (compressed === 'Z') return '0.0.0.0';
|
|
if (compressed.startsWith('P')) {
|
|
const suffix = compressed.substring(1);
|
|
if (suffix.length === 0) return '192.168.0.0';
|
|
if (suffix.length === 1) return `192.168.${suffix}.0`;
|
|
return `192.168.${suffix.substring(0, 1)}.${suffix.substring(1)}`;
|
|
}
|
|
if (compressed.startsWith('T')) {
|
|
const suffix = compressed.substring(1);
|
|
if (suffix.length === 0) return '10.0.0.0';
|
|
if (suffix.length === 1) return `10.${suffix}.0.0`;
|
|
if (suffix.length === 2) return `10.${suffix.substring(0, 1)}.${suffix.substring(1)}.0`;
|
|
return `10.${suffix.substring(0, 1)}.${suffix.substring(1, 2)}.${suffix.substring(2)}`;
|
|
}
|
|
if (compressed.startsWith('S')) {
|
|
const suffix = compressed.substring(1);
|
|
if (suffix.length === 0) return '172.0.0.0';
|
|
if (suffix.length === 1) return `172.${suffix}.0.0`;
|
|
if (suffix.length === 2) return `172.${suffix.substring(0, 1)}.${suffix.substring(1)}.0`;
|
|
return `172.${suffix.substring(0, 1)}.${suffix.substring(1, 2)}.${suffix.substring(2)}`;
|
|
}
|
|
const num = parseInt(compressed, 36);
|
|
return [
|
|
(num >> 24) & 0xFF,
|
|
(num >> 16) & 0xFF,
|
|
(num >> 8) & 0xFF,
|
|
num & 0xFF
|
|
].join('.');
|
|
}
|
|
|
|
function compressNumber(num) {
|
|
return num.toString(36).toUpperCase();
|
|
}
|
|
|
|
function decompressNumber(compressed) {
|
|
return parseInt(compressed, 36);
|
|
}
|
|
|
|
async function validateSDPWithPeerConnection(sdp) {
|
|
return new Promise(async (resolve, reject) => {
|
|
try {
|
|
const pc = new RTCPeerConnection({
|
|
iceServers: [{
|
|
urls: 'stun:stun.l.google.com:19302'
|
|
}]
|
|
});
|
|
const type = sdp.includes('a=setup:actpass') ? 'offer' : 'answer';
|
|
const timeout = setTimeout(() => {
|
|
pc.close();
|
|
reject(new Error("SDP validation timed out"));
|
|
}, 5000);
|
|
pc.onicegatheringstatechange = () => {
|
|
if (pc.iceGatheringState === 'complete') {
|
|
clearTimeout(timeout);
|
|
pc.close();
|
|
resolve(true);
|
|
}
|
|
};
|
|
pc.onicecandidateerror = (event) => {
|
|
console.warn("ICE candidate error:", event);
|
|
};
|
|
pc.oniceconnectionstatechange = () => {
|
|
if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') {
|
|
clearTimeout(timeout);
|
|
pc.close();
|
|
resolve(false);
|
|
}
|
|
};
|
|
try {
|
|
if (type === 'offer') {
|
|
await pc.setRemoteDescription({
|
|
type,
|
|
sdp
|
|
});
|
|
const answer = await pc.createAnswer();
|
|
await pc.setLocalDescription(answer);
|
|
} else {
|
|
pc.createDataChannel('validate');
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
await pc.setRemoteDescription({
|
|
type,
|
|
sdp
|
|
});
|
|
}
|
|
clearTimeout(timeout);
|
|
pc.close();
|
|
resolve(true);
|
|
} catch (err) {
|
|
clearTimeout(timeout);
|
|
pc.close();
|
|
console.error("Error validating SDP:", err);
|
|
reject(err);
|
|
}
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
}
|
|
|
|
function priorityToCompact(priority) {
|
|
return priority.toString(36);
|
|
}
|
|
|
|
function compactToPriority(compact) {
|
|
return parseInt(compact, 36);
|
|
}
|
|
|
|
function hexToCompact(hex) {
|
|
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_";
|
|
let result = '';
|
|
for (let i = 0; i < hex.length; i += 6) {
|
|
const chunk = hex.substr(i, 6);
|
|
if (chunk.length < 6) {
|
|
const value = parseInt(chunk, 16);
|
|
result += chars[value % 64];
|
|
if (chunk.length > 2) {
|
|
result += chars[Math.floor(value / 64) % 64];
|
|
}
|
|
} else {
|
|
const value = parseInt(chunk, 16);
|
|
result += chars[value & 0x3F];
|
|
result += chars[(value >> 6) & 0x3F];
|
|
result += chars[(value >> 12) & 0x3F];
|
|
result += chars[(value >> 18) & 0x3F];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function compactToHex(compact) {
|
|
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_";
|
|
let result = '';
|
|
for (let i = 0; i < compact.length; i += 4) {
|
|
let value = 0;
|
|
for (let j = 0; j < 4 && i + j < compact.length; j++) {
|
|
const char = compact[i + j];
|
|
const charValue = chars.indexOf(char);
|
|
if (charValue === -1) {
|
|
throw new Error(`Invalid character in compact string: ${char}`);
|
|
}
|
|
value |= charValue << (j * 6);
|
|
}
|
|
const hex = value.toString(16).padStart(6, '0');
|
|
result += hex;
|
|
}
|
|
// Ensure result is exactly 64 characters (32 bytes)
|
|
return result.substring(0, 64);
|
|
}
|
|
|
|
async function testSample(name, sdp) {
|
|
const logOutput = document.getElementById('testOutput');
|
|
logOutput.innerHTML += `\n--- Testing ${name} ---\n`;
|
|
try {
|
|
logOutput.innerHTML += `##### Original SDP:\n${sdp}\n`;
|
|
logOutput.innerHTML += `##### Original SDP length: ${sdp.length} bytes\n`;
|
|
const compressed = compressSDP(sdp);
|
|
logOutput.innerHTML += `##### Compressed SDP:\n${compressed}\n`;
|
|
logOutput.innerHTML += `##### Compressed length: ${compressed.length} bytes\n`;
|
|
logOutput.innerHTML += `##### Compression ratio: ${Math.round(compressed.length / sdp.length * 100)}%\n`;
|
|
const decompressed = decompressSDP(compressed);
|
|
logOutput.innerHTML += `##### Decompression successful: ${decompressed.length > 0}\n`;
|
|
logOutput.innerHTML += `##### Decompressed SDP:\n${decompressed}\n`;
|
|
logOutput.innerHTML += `##### Testing decompressed SDP with new peer connection...\n`;
|
|
try {
|
|
const valid = await validateSDPWithPeerConnection(decompressed);
|
|
logOutput.innerHTML += `SDP validation: ${valid ? "PASSED" : "FAILED"}\n`;
|
|
if (!valid) {
|
|
throw new Error(`SDP validation failed for ${name}`);
|
|
}
|
|
} catch (err) {
|
|
logOutput.innerHTML += `SDP validation error: ${err.message}\n`;
|
|
}
|
|
const qrCompressed = compressSDPForQR(sdp);
|
|
logOutput.innerHTML += `QR Compressed length: ${qrCompressed.length} bytes\n`;
|
|
logOutput.innerHTML += `QR Compression ratio: ${Math.round(qrCompressed.length / sdp.length * 100)}%\n`;
|
|
const qrDecompressed = decompressSDPFromQR(qrCompressed);
|
|
logOutput.innerHTML += `QR Decompression successful: ${qrDecompressed.length > 0}\n`;
|
|
return true;
|
|
} catch (err) {
|
|
logOutput.innerHTML += `Error testing ${name}: ${err.message}\n`;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function collectSDPSamples() {
|
|
const samples = {
|
|
offer: {
|
|
withCandidates: null,
|
|
withoutCandidates: null
|
|
},
|
|
answer: {
|
|
withCandidates: null,
|
|
withoutCandidates: null
|
|
}
|
|
};
|
|
|
|
const logOutput = document.getElementById('testOutput');
|
|
logOutput.innerHTML += "Collecting SDP samples...\n";
|
|
|
|
const pc1 = new RTCPeerConnection({
|
|
iceServers: [{
|
|
urls: 'stun:stun.l.google.com:19302'
|
|
}]
|
|
});
|
|
const pc2 = new RTCPeerConnection({
|
|
iceServers: [{
|
|
urls: 'stun:stun.l.google.com:19302'
|
|
}]
|
|
});
|
|
|
|
const dataChannel = pc1.createDataChannel('data');
|
|
const offer = await pc1.createOffer();
|
|
samples.offer.withoutCandidates = offer.sdp;
|
|
await pc1.setLocalDescription(offer);
|
|
await pc2.setRemoteDescription(offer);
|
|
const answer = await pc2.createAnswer();
|
|
samples.answer.withoutCandidates = answer.sdp;
|
|
await pc2.setLocalDescription(answer);
|
|
await pc1.setRemoteDescription(answer);
|
|
|
|
logOutput.innerHTML += "Waiting for ICE gathering to complete...\n";
|
|
await Promise.all([
|
|
waitForIceGathering(pc1),
|
|
waitForIceGathering(pc2)
|
|
]);
|
|
|
|
samples.offer.withCandidates = pc1.localDescription.sdp;
|
|
samples.answer.withCandidates = pc2.localDescription.sdp;
|
|
logOutput.innerHTML += "SDP samples collected successfully.\n";
|
|
|
|
dataChannel.close();
|
|
pc1.close();
|
|
pc2.close();
|
|
return samples;
|
|
}
|
|
|
|
|
|
function compressSDPForQR(sdp) {
|
|
const iceUfrag = sdp.match(/a=ice-ufrag:([^\r\n]+)/)[1];
|
|
const icePwd = sdp.match(/a=ice-pwd:([^\r\n]+)/)[1];
|
|
const fingerprintMatch = sdp.match(/a=fingerprint:sha-256 ([^\r\n]+)/);
|
|
const fingerprint = fingerprintMatch[1].replace(/:/g, '');
|
|
const isOffer = sdp.match(/a=setup:actpass/) !== null;
|
|
const candidates = [];
|
|
const candidateRegex = /a=candidate:([^\r\n]+)/g;
|
|
let candidateMatch;
|
|
while ((candidateMatch = candidateRegex.exec(sdp)) !== null) {
|
|
const parts = candidateMatch[1].split(' ');
|
|
if (parts[4].includes(':')) continue;
|
|
const foundation = parts[0];
|
|
const component = parts[1];
|
|
const protocol = parts[2].toLowerCase() === 'udp' ? '1' : '2';
|
|
const priority = parseInt(parts[3]);
|
|
const ip = compressIP(parts[4]);
|
|
const port = parseInt(parts[5]);
|
|
let typeCode;
|
|
switch(parts[7]) {
|
|
case 'host': typeCode = '1'; break;
|
|
case 'srflx': typeCode = '2'; break;
|
|
case 'relay': typeCode = '3'; break;
|
|
default: typeCode = '0';
|
|
}
|
|
let relInfo = '';
|
|
const rAddrIndex = parts.indexOf('raddr');
|
|
const rPortIndex = parts.indexOf('rport');
|
|
if (rAddrIndex !== -1 && rPortIndex !== -1 &&
|
|
rAddrIndex + 1 < parts.length && rPortIndex + 1 < parts.length) {
|
|
relInfo = compressIP(parts[rAddrIndex + 1]) + parts[rPortIndex + 1];
|
|
}
|
|
candidates.push(`${foundation}${component}${protocol}${compressNumber(priority)}${ip}${port}${typeCode}${relInfo}`);
|
|
}
|
|
const qrFingerprint = fingerprintToQR(fingerprint);
|
|
let result = `Q${isOffer ? '1' : '0'}${iceUfrag}~${icePwd}~${qrFingerprint}`;
|
|
if (candidates.length > 0) {
|
|
result += `~${candidates.join(',')}`;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function decompressSDPFromQR(compressed) {
|
|
if (!compressed.startsWith('Q')) {
|
|
throw new Error('Unsupported QR compression format');
|
|
}
|
|
const isOffer = compressed[1] === '1';
|
|
const parts = compressed.substring(2).split('~');
|
|
const iceUfrag = parts[0];
|
|
const icePwd = parts[1];
|
|
const fingerprint = qrToFingerprint(parts[2]);
|
|
const formattedFingerprint = fingerprint.match(/.{2}/g).join(':');
|
|
let sdp = [
|
|
'v=0',
|
|
'o=- ' + Math.floor(Date.now()) + ' 2 IN IP4 127.0.0.1',
|
|
's=-',
|
|
't=0 0',
|
|
'a=group:BUNDLE 0',
|
|
'a=extmap-allow-mixed',
|
|
'a=msid-semantic: WMS',
|
|
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
|
|
'c=IN IP4 0.0.0.0',
|
|
`a=ice-ufrag:${iceUfrag}`,
|
|
`a=ice-pwd:${icePwd}`,
|
|
'a=ice-options:trickle',
|
|
`a=fingerprint:sha-256 ${formattedFingerprint}`,
|
|
`a=setup:${isOffer ? 'actpass' : 'active'}`,
|
|
'a=mid:0'
|
|
].join('\r\n');
|
|
if (parts.length > 3 && parts[3]) {
|
|
const candidates = parts[3].split(',');
|
|
let candidateLines = '';
|
|
for (const candidate of candidates) {
|
|
let i = 0;
|
|
let j = i;
|
|
while (j < candidate.length && /^\d$/.test(candidate[j])) j++;
|
|
const foundation = candidate.substring(i, j);
|
|
i = j;
|
|
if (i >= candidate.length) continue;
|
|
const component = candidate.substring(i, i + 1);
|
|
i += 1;
|
|
if (i >= candidate.length) continue;
|
|
const protocolCode = candidate.substring(i, i + 1);
|
|
const protocol = protocolCode === '1' ? 'UDP' : 'TCP';
|
|
i += 1;
|
|
if (i >= candidate.length) continue;
|
|
j = i;
|
|
while (j < candidate.length && /^[0-9A-Z]$/.test(candidate[j])) j++;
|
|
const priorityEncoded = candidate.substring(i, j);
|
|
const priority = decompressNumber(priorityEncoded);
|
|
i = j;
|
|
if (i >= candidate.length) continue;
|
|
j = i;
|
|
while (j < candidate.length && !/^\d$/.test(candidate[j]) && candidate[j] !== '1' &&
|
|
candidate[j] !== '2' && candidate[j] !== '3' && candidate[j] !== '0') j++;
|
|
const ipEncoded = candidate.substring(i, j);
|
|
const ip = decompressIP(ipEncoded);
|
|
i = j;
|
|
if (i >= candidate.length) continue;
|
|
j = i;
|
|
while (j < candidate.length && /^\d$/.test(candidate[j])) j++;
|
|
const port = candidate.substring(i, j);
|
|
i = j;
|
|
if (i >= candidate.length) continue;
|
|
const typeCode = candidate.substring(i, i + 1);
|
|
i += 1;
|
|
let type;
|
|
switch(typeCode) {
|
|
case '1': type = 'host'; break;
|
|
case '2': type = 'srflx'; break;
|
|
case '3': type = 'relay'; break;
|
|
default: type = 'unknown';
|
|
}
|
|
candidateLines += `\r\na=candidate:${foundation} ${component} ${protocol} ${priority} ${ip} ${port} typ ${type}`;
|
|
if (i < candidate.length && (type === 'srflx' || type === 'relay')) {
|
|
j = i;
|
|
while (j < candidate.length && !/^\d$/.test(candidate[j])) j++;
|
|
const relatedIPEncoded = candidate.substring(i, j);
|
|
const relatedIP = decompressIP(relatedIPEncoded);
|
|
i = j;
|
|
j = i;
|
|
while (j < candidate.length && /^\d$/.test(candidate[j])) j++;
|
|
const relatedPort = candidate.substring(i, j);
|
|
if (relatedIP && relatedPort) {
|
|
candidateLines += ` raddr ${relatedIP} rport ${relatedPort}`;
|
|
}
|
|
}
|
|
if (protocol === 'TCP') {
|
|
candidateLines += ' tcptype active';
|
|
}
|
|
}
|
|
sdp += candidateLines;
|
|
if (candidateLines) {
|
|
sdp += '\r\na=end-of-candidates';
|
|
}
|
|
}
|
|
return sdp;
|
|
}
|
|
|
|
|
|
function fingerprintToQR(hex) {
|
|
let result = '';
|
|
for (let i = 0; i < hex.length; i += 4) {
|
|
const chunk = hex.substr(i, 4);
|
|
if (chunk.length < 4) {
|
|
const value = parseInt(chunk, 16);
|
|
result += value.toString(36).toUpperCase().padStart(3, '0').substring(0, 3);
|
|
} else {
|
|
const value = parseInt(chunk, 16);
|
|
result += value.toString(36).toUpperCase().padStart(3, '0').substring(0, 3);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function qrToFingerprint(qrFp) {
|
|
let result = '';
|
|
for (let i = 0; i < qrFp.length; i += 3) {
|
|
const chunk = qrFp.substr(i, 3);
|
|
const value = parseInt(chunk, 36);
|
|
result += value.toString(16).padStart(4, '0').substring(0, 4);
|
|
}
|
|
return result.padEnd(64, '0');
|
|
}
|
|
|
|
function sdpToQRCode(sdp, element) {
|
|
const compressed = compressSDPForQR(sdp);
|
|
new QRCode(element, {
|
|
text: compressed,
|
|
width: 128,
|
|
height: 128,
|
|
colorDark: "#000000",
|
|
colorLight: "#ffffff",
|
|
correctLevel: QRCode.CorrectLevel.M
|
|
});
|
|
return compressed;
|
|
}
|
|
|
|
function compressICECandidates(candidates) {
|
|
try {
|
|
if (!candidates || !candidates.length) return "";
|
|
const hostCandidates = candidates
|
|
.filter(c => c.candidate && c.candidate.includes('typ host') && !c.candidate.includes(':'))
|
|
.slice(0, 2);
|
|
if (!hostCandidates.length) return "";
|
|
const compressed = hostCandidates.map(c => {
|
|
const parts = c.candidate.split(' ');
|
|
return `${parts[0].replace('candidate:', '')},${parts[1]},${parts[2].toLowerCase() === 'udp' ? 'u' : 't'},${parts[4]},${parts[5]},h`;
|
|
});
|
|
return compressed.join('/');
|
|
} catch (e) {
|
|
console.error("Error compressing ICE candidates:", e);
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function decompressICECandidates(compressed) {
|
|
try {
|
|
if (!compressed) return [];
|
|
const candidates = compressed.split('/').map(c => {
|
|
const parts = c.split(',');
|
|
if (parts.length < 6) return null;
|
|
const foundation = parts[0];
|
|
const component = parts[1];
|
|
const protocol = parts[2] === 'u' ? 'UDP' : 'TCP';
|
|
const ip = parts[3];
|
|
const port = parts[4];
|
|
const type = parts[5] === 'h' ? 'host' :
|
|
(parts[5] === 's' ? 'srflx' :
|
|
(parts[5] === 'r' ? 'relay' : 'host'));
|
|
const priority = type === 'host' ? 2122260223 :
|
|
(type === 'srflx' ? 1686052607 :
|
|
(type === 'relay' ? 1685987071 : 1684797951));
|
|
return {
|
|
candidate: `candidate:${foundation} ${component} ${protocol} ${priority} ${ip} ${port} typ ${type}`,
|
|
sdpMid: "0",
|
|
sdpMLineIndex: 0,
|
|
usernameFragment: null
|
|
};
|
|
}).filter(c => c !== null);
|
|
return candidates;
|
|
} catch (e) {
|
|
console.error("Error decompressing ICE candidates:", e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
|
|
function waitForIceGathering(pc) {
|
|
return new Promise(resolve => {
|
|
if (pc.iceGatheringState === 'complete') {
|
|
resolve();
|
|
return;
|
|
}
|
|
const checkState = () => {
|
|
if (pc.iceGatheringState === 'complete') {
|
|
pc.removeEventListener('icegatheringstatechange', checkState);
|
|
resolve();
|
|
}
|
|
};
|
|
pc.addEventListener('icegatheringstatechange', checkState);
|
|
setTimeout(resolve, 5000);
|
|
});
|
|
}
|
|
|
|
async function runAllTests() {
|
|
const logOutput = document.getElementById('testOutput');
|
|
logOutput.innerHTML = "=== SDP COMPRESSION/DECOMPRESSION TEST TOOL ===\n";
|
|
logOutput.innerHTML += "Generating and testing various SDP types...\n";
|
|
|
|
try {
|
|
const samples = await collectSDPSamples();
|
|
await testSample("Offer with candidates", samples.offer.withCandidates);
|
|
await testSample("Offer without candidates", samples.offer.withoutCandidates);
|
|
await testSample("Answer with candidates", samples.answer.withCandidates);
|
|
await testSample("Answer without candidates", samples.answer.withoutCandidates);
|
|
logOutput.innerHTML += "\n=== ALL TESTS COMPLETED SUCCESSFULLY ===\n";
|
|
} catch (err) {
|
|
logOutput.innerHTML += `Test failed: ${err.message}\n`;
|
|
console.error("Test failed:", err);
|
|
}
|
|
}
|
|
|
|
// When the DOM is loaded, set up event handlers
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Tab switching
|
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
const tabId = tab.getAttribute('data-tab');
|
|
|
|
// Update active tab
|
|
document.querySelectorAll('.tab').forEach(t => {
|
|
t.classList.remove('active');
|
|
});
|
|
tab.classList.add('active');
|
|
|
|
// Update active content
|
|
document.querySelectorAll('.tab-content').forEach(content => {
|
|
content.classList.remove('active');
|
|
});
|
|
document.getElementById(tabId).classList.add('active');
|
|
});
|
|
});
|
|
|
|
// SDP Compression Tab
|
|
document.getElementById('compressBtn').addEventListener('click', () => {
|
|
const sdpInput = document.getElementById('sdpInput').value;
|
|
try {
|
|
const compressed = compressSDP(sdpInput);
|
|
document.getElementById('compressedOutput').value = compressed;
|
|
document.getElementById('originalSize').textContent = sdpInput.length;
|
|
document.getElementById('compressedSize').textContent = compressed.length;
|
|
document.getElementById('compressionRatio').textContent =
|
|
Math.round(compressed.length / sdpInput.length * 100);
|
|
// Clear any previous QR code
|
|
document.getElementById('qrcode').innerHTML = '';
|
|
} catch (err) {
|
|
alert('Error compressing SDP: ' + err.message);
|
|
console.error('Error compressing SDP:', err);
|
|
}
|
|
});
|
|
|
|
document.getElementById('compressQRBtn').addEventListener('click', () => {
|
|
const sdpInput = document.getElementById('sdpInput').value;
|
|
try {
|
|
const compressed = compressSDPForQR(sdpInput);
|
|
document.getElementById('compressedOutput').value = compressed;
|
|
document.getElementById('originalSize').textContent = sdpInput.length;
|
|
document.getElementById('compressedSize').textContent = compressed.length;
|
|
document.getElementById('compressionRatio').textContent =
|
|
Math.round(compressed.length / sdpInput.length * 100);
|
|
|
|
// Generate QR code
|
|
document.getElementById('qrcode').innerHTML = '';
|
|
new QRCode(document.getElementById('qrcode'), {
|
|
text: compressed,
|
|
width: 200,
|
|
height: 200,
|
|
colorDark: "#000000",
|
|
colorLight: "#ffffff",
|
|
correctLevel: QRCode.CorrectLevel.M
|
|
});
|
|
} catch (err) {
|
|
alert('Error compressing SDP for QR: ' + err.message);
|
|
console.error('Error compressing SDP for QR:', err);
|
|
}
|
|
});
|
|
|
|
document.getElementById('decompressBtn').addEventListener('click', () => {
|
|
const compressedSDP = document.getElementById('compressedOutput').value;
|
|
try {
|
|
const decompressed = decompressSDP(compressedSDP);
|
|
document.getElementById('decompressedOutput').value = decompressed;
|
|
} catch (err) {
|
|
alert('Error decompressing SDP: ' + err.message);
|
|
console.error('Error decompressing SDP:', err);
|
|
}
|
|
});
|
|
|
|
document.getElementById('decompressQRBtn').addEventListener('click', () => {
|
|
const compressedSDP = document.getElementById('compressedOutput').value;
|
|
try {
|
|
const decompressed = decompressSDPFromQR(compressedSDP);
|
|
document.getElementById('decompressedOutput').value = decompressed;
|
|
} catch (err) {
|
|
alert('Error decompressing QR SDP: ' + err.message);
|
|
console.error('Error decompressing QR SDP:', err);
|
|
}
|
|
});
|
|
|
|
document.getElementById('copyCompressedBtn').addEventListener('click', () => {
|
|
const compressedOutput = document.getElementById('compressedOutput');
|
|
compressedOutput.select();
|
|
document.execCommand('copy');
|
|
alert('Compressed SDP copied to clipboard');
|
|
});
|
|
|
|
document.getElementById('generateSampleSDPBtn').addEventListener('click', async () => {
|
|
try {
|
|
const pc = new RTCPeerConnection({
|
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
});
|
|
pc.createDataChannel('sample');
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
|
|
// Wait for a short time to allow some ICE candidates to be generated
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
document.getElementById('sdpInput').value = pc.localDescription.sdp;
|
|
pc.close();
|
|
} catch (err) {
|
|
alert('Error generating sample SDP: ' + err.message);
|
|
console.error('Error generating sample SDP:', err);
|
|
}
|
|
});
|
|
|
|
// Testing Tab
|
|
document.getElementById('runTestsBtn').addEventListener('click', async () => {
|
|
await runAllTests();
|
|
});
|
|
|
|
document.getElementById('validateSDPBtn').addEventListener('click', async () => {
|
|
const sdp = document.getElementById('decompressedOutput').value;
|
|
if (!sdp) {
|
|
alert('Please decompress an SDP first');
|
|
return;
|
|
}
|
|
|
|
const logOutput = document.getElementById('testOutput');
|
|
logOutput.innerHTML = "Validating SDP...\n";
|
|
|
|
try {
|
|
const valid = await validateSDPWithPeerConnection(sdp);
|
|
logOutput.innerHTML += `SDP validation: ${valid ? "PASSED" : "FAILED"}\n`;
|
|
} catch (err) {
|
|
logOutput.innerHTML += `SDP validation error: ${err.message}\n`;
|
|
console.error('SDP validation error:', err);
|
|
}
|
|
});
|
|
|
|
// ICE Candidates Tab
|
|
document.getElementById('compressIceBtn').addEventListener('click', () => {
|
|
try {
|
|
const iceInput = document.getElementById('iceInput').value;
|
|
const candidates = JSON.parse(iceInput);
|
|
const compressed = compressICECandidates(candidates);
|
|
document.getElementById('iceCompressedOutput').value = compressed;
|
|
} catch (err) {
|
|
alert('Error compressing ICE candidates: ' + err.message);
|
|
console.error('Error compressing ICE candidates:', err);
|
|
}
|
|
});
|
|
|
|
document.getElementById('decompressIceBtn').addEventListener('click', () => {
|
|
try {
|
|
const compressedIce = document.getElementById('iceCompressedOutput').value;
|
|
const candidates = decompressICECandidates(compressedIce);
|
|
document.getElementById('iceDecompressedOutput').value = JSON.stringify(candidates, null, 2);
|
|
} catch (err) {
|
|
alert('Error decompressing ICE candidates: ' + err.message);
|
|
console.error('Error decompressing ICE candidates:', err);
|
|
}
|
|
});
|
|
|
|
document.getElementById('copyCompressedIceBtn').addEventListener('click', () => {
|
|
const compressedOutput = document.getElementById('iceCompressedOutput');
|
|
compressedOutput.select();
|
|
document.execCommand('copy');
|
|
alert('Compressed ICE copied to clipboard');
|
|
});
|
|
|
|
document.getElementById('generateSampleIceBtn').addEventListener('click', async () => {
|
|
try {
|
|
const pc = new RTCPeerConnection({
|
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
});
|
|
|
|
const candidates = [];
|
|
|
|
pc.onicecandidate = (event) => {
|
|
if (event.candidate) {
|
|
candidates.push(event.candidate);
|
|
document.getElementById('iceInput').value = JSON.stringify(candidates, null, 2);
|
|
}
|
|
};
|
|
|
|
pc.createDataChannel('sample');
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
|
|
// Automatically close after 3 seconds
|
|
setTimeout(() => {
|
|
pc.close();
|
|
if (candidates.length === 0) {
|
|
document.getElementById('iceInput').value = JSON.stringify([{
|
|
candidate: "candidate:1853936366 1 UDP 2122260223 192.168.1.2 56789 typ host",
|
|
sdpMid: "0",
|
|
sdpMLineIndex: 0
|
|
}], null, 2);
|
|
}
|
|
}, 3000);
|
|
} catch (err) {
|
|
alert('Error generating sample ICE candidates: ' + err.message);
|
|
console.error('Error generating sample ICE candidates:', err);
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |