websocket 局域网 webrtc 一对一 多对多 视频通话 的示例
基本介绍
WebRTC(Web Real-Time Communications)是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。WebRTC 包含的这些标准使用户在无需安装任何插件或者第三方的软件的情况下,创建点对点(Peer-to-Peer)的数据分享和电话会议成为可能。
请浏览 MDN文档 WebRTC API 中文指南 了解WebRTC
使用websocket实现 WebRTC 建立连接时的信令服务,即交换传递 SDP和ICE两种数据,SDP分为Offer和Answer两类数据。
实现了一对一视频通话、共享桌面、多对多 监控页面、挂断后保存视频到本地 等功能。
效果:

局域网测试 一对一 视频 40-80ms延迟:

多对多 监控页

异常问题
1.http协议下安全性原因导致无法调用摄像头和麦克风
chrome://flags/ 配置安全策略 或者 配置本地的https环境

2. 开启了防火墙,webRTC连接失败,
windows防火墙-高级设置-入站规则-新建规则-端口 ,udp
UDP: 32355-65535 放行
方便测试 直接关闭防火墙也行。
3. 录制webm视频 没有时间轴,使用fix-webm-duration js解决。
demo代码如下
一对一 通话页面
<!DOCTYPE>
<html><head><meta charset="UTF-8"><title>WebRTC + WebSocket</title><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><style>html,body {margin: 0;padding: 0;}#main {position: absolute;width: 100%;height: 100%;}#localVideo {position: absolute;background: #757474;top: 10px;right: 10px;width: 200px;/* height: 150px; */z-index: 2;}#remoteVideo {position: absolute;top: 0px;left: 0px;width: 100%;height: 100%;background: #222;}#buttons {z-index: 3;bottom: 20px;left: 20px;position: absolute;}input {border: 1px solid #ccc;padding: 7px 0px;border-radius: 5px;padding-left: 5px;margin-bottom: 5px;}input :focus {border-color: #66afe9;outline: 0;-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px rgba(102, 175, 233, .6);box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px rgba(102, 175, 233, .6)}#call {width: 70px;height: 35px;background-color: #00BB00;border: none;color: white;border-radius: 5px;}#hangup {width: 70px;height: 35px;background-color: #FF5151;border: none;color: white;border-radius: 5px;}button {width: 70px;height: 35px;margin: 6px;background-color: #6de73d;border: none;color: white;border-radius: 5px;}</style>
</head><body><div id="main"><video id="remoteVideo" playsinline autoplay></video><video id="localVideo" playsinline autoplay muted></video><div id="buttons"><input id="myid" readonly /><br /><input id="toUser" placeholder="输入在线好友账号" /><br /><button id="call" onclick="call(true)">视频通话</button><button id="deskcall" onclick="call(false)">分享桌面</button><br /><button id="hangup">挂断</button></div></div>
</body>
<!-- 可引可不引 -->
<!--<script src="/js/adapter-2021.js" type="text/javascript"></script>-->
<script src="./fix-webm-duration.js" type="text/javascript"></script>
<script type="text/javascript">function generateRandomLetters(length) {let result = '';const characters = 'abcdefghijklmnopqrstuvwxyz'; // 字母表for (let i = 0; i < length; i++) {const randomIndex = Math.floor(Math.random() * characters.length);const randomLetter = characters[randomIndex];result += randomLetter;}return result;}let username = generateRandomLetters(2);document.getElementById('myid').value = username;let localVideo = document.getElementById('localVideo');let remoteVideo = document.getElementById('remoteVideo');let websocket = null;let peer = {};let candidate = null;let stream = null;/* WebSocket */function WebSocketInit() {//判断当前浏览器是否支持WebSocketif ('WebSocket' in window) {websocket = new WebSocket("ws://192.168.31.14:8181/webrtc/" + username);} else {alert("当前浏览器不支持WebSocket!");}//连接发生错误的回调方法websocket.onerror = function (e) {alert("WebSocket连接发生错误!");};//连接关闭的回调方法websocket.onclose = function () {console.error("WebSocket连接关闭");};//连接成功建立的回调方法websocket.onopen = function () {console.log("WebSocket连接成功");};//接收到消息的回调方法websocket.onmessage = async function (event) {let { type, fromUser, toUser, msg, sdp, iceCandidate } = JSON.parse(event.data.replace(/
/g, "
").replace(/
/g, “\r”));
console.log(type, fromUser, toUser);if (type === 'hangup') {console.log(msg);document.getElementById('hangup').click();return;}if (type === 'call_start') {document.getElementById('hangup').click();let msg = "0"if (confirm(fromUser + "发起视频通话,确定接听吗") == true) {document.getElementById('toUser').value = fromUser;WebRTCInit(fromUser);msg = "1"document.getElementById('toUser').style.visibility = 'hidden';document.getElementById('myid').style.visibility = 'hidden';}websocket.send(JSON.stringify({type: "call_back",toUser: fromUser,fromUser: username,msg: msg}));return;}if (type === 'call_back') {if (msg === "1") {console.log(document.getElementById('toUser').value + "同意视频通话");await getVideo(callType);console.log(peer, fromUser)stream.getTracks().forEach(track => {peer[fromUser].addTrack(track, stream);});let offer = await peer[fromUser].createOffer();await peer[fromUser].setLocalDescription(offer);let newOffer = {...offer};console.log(newOffer)newOffer["fromUser"] = username;newOffer["toUser"] = document.getElementById('toUser').value;websocket.send(JSON.stringify(newOffer));} else if (msg === "0") {alert(document.getElementById('toUser').value + "拒绝视频通话");document.getElementById('hangup').click();} else {alert(msg);document.getElementById('hangup').click();}return;}if (type === 'offer') {await getVideo(callType);console.log(peer, fromUser, stream)stream.getTracks().forEach(track => {peer[fromUser].addTrack(track, stream);});await peer[fromUser].setRemoteDescription(new RTCSessionDescription({ type, sdp }));let answer = await peer[fromUser].createAnswer();newAnswer = {...answer}console.log(newAnswer)newAnswer["fromUser"] = username;newAnswer["toUser"] = fromUser;websocket.send(JSON.stringify(newAnswer));await peer[fromUser].setLocalDescription(newAnswer);return;}if (type === 'answer') {peer[fromUser].setRemoteDescription(new RTCSessionDescription({ type, sdp }));return;}if (type === '_ice') {peer[fromUser].addIceCandidate(iceCandidate);return;}if (type === 'getStream') {WebRTCInit(fromUser);stream.getTracks().forEach(track => {peer[fromUser].addTrack(track, stream);});let offer = await peer[fromUser].createOffer();await peer[fromUser].setLocalDescription(offer);let newOffer ={...offer}newOffer["fromUser"] = username;newOffer["toUser"] = fromUser;websocket.send(JSON.stringify(newOffer));}}}async function getVideo(callType) {if (stream == null) {if (callType) {//创建本地视频并发送offerstream = await navigator.mediaDevices.getUserMedia({video: {width: 2560, height: 1440,// // width: 1920, height: 1080,// width: 1280, height: 720,// // width: 640, height: 480,// // 强制后置// // facingMode: { exact: "environment" },// // 前置 如果有的话// facingMode: "user",// // 受限带宽传输时,采用低帧率frameRate: { ideal: 25, max: 30 }},// video: true,// audio: trueaudio: {// 自动增益autoGainControl: true,// 回音消除echoCancellation: true,// 噪音抑制noiseSuppression: true}})} else {stream = await navigator.mediaDevices.getDisplayMedia({ video: true })}startRecorder(stream);localVideo.srcObject = stream;}}/* WebRTC */function WebRTCInit(userId) {if (peer[userId]) {peer[userId].close();}const p = new RTCPeerConnection();//icep.onicecandidate = function (e) {if (e.candidate) {websocket.send(JSON.stringify({type: '_ice',toUser: userId,fromUser: username,iceCandidate: e.candidate}));}};//trackp.ontrack = function (e) {if (e && e.streams) {remoteVideo.srcObject = e.streams[0];}};peer[userId] = p;console.log(peer)}// let callType = true;function call(ct) {let toUser = document.getElementById('toUser').value;if (!toUser) {alert("请先指定好友账号,再发起视频通话!");return;}callType = ct;document.getElementById('toUser').style.visibility = 'hidden';document.getElementById('myid').style.visibility = 'hidden';if (!peer[toUser]) {WebRTCInit(toUser);}websocket.send(JSON.stringify({type: "call_start",fromUser: username,toUser: toUser,}));}/* 按钮事件 */function ButtonFunInit() {//挂断document.getElementById('hangup').onclick = function (e) {document.getElementById('toUser').style.visibility = 'unset';document.getElementById('myid').style.visibility = 'unset';if (stream) {stream.getTracks().forEach(track => {track.stop();});stream = null;}// 停止录像mediaRecorder.stop();if (remoteVideo.srcObject) {remoteVideo.srcObject.getTracks.forEach(track => track.stop());//挂断同时,通知对方websocket.send(JSON.stringify({type: "hangup",fromUser: username,toUser: document.getElementById('toUser').value,}));}Object.values(peer).forEach(peer => peer.close())peer = {};localVideo.srcObject = null;remoteVideo.srcObject = null;}}WebSocketInit();ButtonFunInit();// 录制视频let mediaRecorder;let recordedBlobs = [];let startTime = 0;function startRecorder(stream) {mediaRecorder = new MediaRecorder(stream);// 录制开始时触发mediaRecorder.onstart = () => {recordedBlobs = [];};// 录制过程中触发mediaRecorder.ondataavailable = event => {if (event.data && event.data.size > 0) {recordedBlobs.push(event.data);}};// 录制结束时触发mediaRecorder.onstop = () => {console.log(recordedBlobs[0].type);const blob = new Blob(recordedBlobs, { type: 'video/mp4;codecs=vp8,opus' });ysFixWebmDuration(blob, Date.now() - startTime, function (fixedBlob) {const url = window.URL.createObjectURL(fixedBlob);const a = document.createElement('a');const filename = 'recorded-video.mp4';a.style.display = 'none';a.href = url;a.download = filename;document.body.appendChild(a);a.click();window.URL.revokeObjectURL(url);});};mediaRecorder.start();startTime = Date.now();}</script></html>
fix-webm-duration.js
(function (name, definition) {if (typeof define === "function" && define.amd) {// RequireJS / AMDdefine(definition);} else if (typeof module !== "undefined" && module.exports) {// CommonJS / Node.jsmodule.exports = definition();} else {// Direct includewindow.ysFixWebmDuration = definition();}
})("fix-webm-duration", function () {/** This is the list of possible WEBM file sections by their IDs.* Possible types: Container, Binary, Uint, Int, String, Float, Date*/var sections = {0xa45dfa3: { name: "EBML", type: "Container" },0x286: { name: "EBMLVersion", type: "Uint" },0x2f7: { name: "EBMLReadVersion", type: "Uint" },0x2f2: { name: "EBMLMaxIDLength", type: "Uint" },0x2f3: { name: "EBMLMaxSizeLength", type: "Uint" },0x282: { name: "DocType", type: "String" },0x287: { name: "DocTypeVersion", type: "Uint" },0x285: { name: "DocTypeReadVersion", type: "Uint" },0x6c: { name: "Void", type: "Binary" },0x3f: { name: "CRC-32", type: "Binary" },0xb538667: { name: "SignatureSlot", type: "Container" },0x3e8a: { name: "SignatureAlgo", type: "Uint" },0x3e9a: { name: "SignatureHash", type: "Uint" },0x3ea5: { name: "SignaturePublicKey", type: "Binary" },0x3eb5: { name: "Signature", type: "Binary" },0x3e5b: { name: "SignatureElements", type: "Container" },0x3e7b: { name: "SignatureElementList", type: "Container" },0x2532: { name: "SignedElement", type: "Binary" },0x8538067: { name: "Segment", type: "Container" },0x14d9b74: { name: "SeekHead", type: "Container" },0xdbb: { name: "Seek", type: "Container" },0x13ab: { name: "SeekID", type: "Binary" },0x13ac: { name: "SeekPosition", type: "Uint" },0x549a966: { name: "Info", type: "Container" },0x33a4: { name: "SegmentUID", type: "Binary" },0x3384: { name: "SegmentFilename", type: "String" },0x1cb923: { name: "PrevUID", type: "Binary" },0x1c83ab: { name: "PrevFilename", type: "String" },0x1eb923: { name: "NextUID", type: "Binary" },0x1e83bb: { name: "NextFilename", type: "String" },0x444: { name: "SegmentFamily", type: "Binary" },0x2924: { name: "ChapterTranslate", type: "Container" },0x29fc: { name: "ChapterTranslateEditionUID", type: "Uint" },0x29bf: { name: "ChapterTranslateCodec", type: "Uint" },0x29a5: { name: "ChapterTranslateID", type: "Binary" },0xad7b1: { name: "TimecodeScale", type: "Uint" },0x489: { name: "Duration", type: "Float" },0x461: { name: "DateUTC", type: "Date" },0x3ba9: { name: "Title", type: "String" },0xd80: { name: "MuxingApp", type: "String" },0x1741: { name: "WritingApp", type: "String" },// 0xf43b675: { name: 'Cluster', type: 'Container' },0x67: { name: "Timecode", type: "Uint" },0x1854: { name: "SilentTracks", type: "Container" },0x18d7: { name: "SilentTrackNumber", type: "Uint" },0x27: { name: "Position", type: "Uint" },0x2b: { name: "PrevSize", type: "Uint" },0x23: { name: "SimpleBlock", type: "Binary" },0x20: { name: "BlockGroup", type: "Container" },0x21: { name: "Block", type: "Binary" },0x22: { name: "BlockVirtual", type: "Binary" },0x35a1: { name: "BlockAdditions", type: "Container" },0x26: { name: "BlockMore", type: "Container" },0x6e: { name: "BlockAddID", type: "Uint" },0x25: { name: "BlockAdditional", type: "Binary" },0x1b: { name: "BlockDuration", type: "Uint" },0x7a: { name: "ReferencePriority", type: "Uint" },0x7b: { name: "ReferenceBlock", type: "Int" },0x7d: { name: "ReferenceVirtual", type: "Int" },0x24: { name: "CodecState", type: "Binary" },0x35a2: { name: "DiscardPadding", type: "Int" },0xe: { name: "Slices", type: "Container" },0x68: { name: "TimeSlice", type: "Container" },0x4c: { name: "LaceNumber", type: "Uint" },0x4d: { name: "FrameNumber", type: "Uint" },0x4b: { name: "BlockAdditionID", type: "Uint" },0x4e: { name: "Delay", type: "Uint" },0x4f: { name: "SliceDuration", type: "Uint" },0x48: { name: "ReferenceFrame", type: "Container" },0x49: { name: "ReferenceOffset", type: "Uint" },0x4a: { name: "ReferenceTimeCode", type: "Uint" },0x2f: { name: "EncryptedBlock", type: "Binary" },0x654ae6b: { name: "Tracks", type: "Container" },0x2e: { name: "TrackEntry", type: "Container" },0x57: { name: "TrackNumber", type: "Uint" },0x33c5: { name: "TrackUID", type: "Uint" },0x3: { name: "TrackType", type: "Uint" },0x39: { name: "FlagEnabled", type: "Uint" },0x8: { name: "FlagDefault", type: "Uint" },0x15aa: { name: "FlagForced", type: "Uint" },0x1c: { name: "FlagLacing", type: "Uint" },0x2de7: { name: "MinCache", type: "Uint" },0x2df8: { name: "MaxCache", type: "Uint" },0x3e383: { name: "DefaultDuration", type: "Uint" },0x34e7a: { name: "DefaultDecodedFieldDuration", type: "Uint" },0x3314f: { name: "TrackTimecodeScale", type: "Float" },0x137f: { name: "TrackOffset", type: "Int" },0x15ee: { name: "MaxBlockAdditionID", type: "Uint" },0x136e: { name: "Name", type: "String" },0x2b59c: { name: "Language", type: "String" },0x6: { name: "CodecID", type: "String" },0x23a2: { name: "CodecPrivate", type: "Binary" },0x58688: { name: "CodecName", type: "String" },0x3446: { name: "AttachmentLink", type: "Uint" },0x1a9697: { name: "CodecSettings", type: "String" },0x1b4040: { name: "CodecInfoURL", type: "String" },0x6b240: { name: "CodecDownloadURL", type: "String" },0x2a: { name: "CodecDecodeAll", type: "Uint" },0x2fab: { name: "TrackOverlay", type: "Uint" },0x16aa: { name: "CodecDelay", type: "Uint" },0x16bb: { name: "SeekPreRoll", type: "Uint" },0x2624: { name: "TrackTranslate", type: "Container" },0x26fc: { name: "TrackTranslateEditionUID", type: "Uint" },0x26bf: { name: "TrackTranslateCodec", type: "Uint" },0x26a5: { name: "TrackTranslateTrackID", type: "Binary" },0x60: { name: "Video", type: "Container" },0x1a: { name: "FlagInterlaced", type: "Uint" },0x13b8: { name: "StereoMode", type: "Uint" },0x13c0: { name: "AlphaMode", type: "Uint" },0x13b9: { name: "OldStereoMode", type: "Uint" },0x30: { name: "PixelWidth", type: "Uint" },0x3a: { name: "PixelHeight", type: "Uint" },0x14aa: { name: "PixelCropBottom", type: "Uint" },0x14bb: { name: "PixelCropTop", type: "Uint" },0x14cc: { name: "PixelCropLeft", type: "Uint" },0x14dd: { name: "PixelCropRight", type: "Uint" },0x14b0: { name: "DisplayWidth", type: "Uint" },0x14ba: { name: "DisplayHeight", type: "Uint" },0x14b2: { name: "DisplayUnit", type: "Uint" },0x14b3: { name: "AspectRatioType", type: "Uint" },0xeb524: { name: "ColourSpace", type: "Binary" },0xfb523: { name: "GammaValue", type: "Float" },0x383e3: { name: "FrameRate", type: "Float" },0x61: { name: "Audio", type: "Container" },0x35: { name: "SamplingFrequency", type: "Float" },0x38b5: { name: "OutputSamplingFrequency", type: "Float" },0x1f: { name: "Channels", type: "Uint" },0x3d7b: { name: "ChannelPositions", type: "Binary" },0x2264: { name: "BitDepth", type: "Uint" },0x62: { name: "TrackOperation", type: "Container" },0x63: { name: "TrackCombinePlanes", type: "Container" },0x64: { name: "TrackPlane", type: "Container" },0x65: { name: "TrackPlaneUID", type: "Uint" },0x66: { name: "TrackPlaneType", type: "Uint" },0x69: { name: "TrackJoinBlocks", type: "Container" },0x6d: { name: "TrackJoinUID", type: "Uint" },0x40: { name: "TrickTrackUID", type: "Uint" },0x41: { name: "TrickTrackSegmentUID", type: "Binary" },0x46: { name: "TrickTrackFlag", type: "Uint" },0x47: { name: "TrickMasterTrackUID", type: "Uint" },0x44: { name: "TrickMasterTrackSegmentUID", type: "Binary" },0x2d80: { name: "ContentEncodings", type: "Container" },0x2240: { name: "ContentEncoding", type: "Container" },0x1031: { name: "ContentEncodingOrder", type: "Uint" },0x1032: { name: "ContentEncodingScope", type: "Uint" },0x1033: { name: "ContentEncodingType", type: "Uint" },0x1034: { name: "ContentCompression", type: "Container" },0x254: { name: "ContentCompAlgo", type: "Uint" },0x255: { name: "ContentCompSettings", type: "Binary" },0x1035: { name: "ContentEncryption", type: "Container" },0x7e1: { name: "ContentEncAlgo", type: "Uint" },0x7e2: { name: "ContentEncKeyID", type: "Binary" },0x7e3: { name: "ContentSignature", type: "Binary" },0x7e4: { name: "ContentSigKeyID", type: "Binary" },0x7e5: { name: "ContentSigAlgo", type: "Uint" },0x7e6: { name: "ContentSigHashAlgo", type: "Uint" },0xc53bb6b: { name: "Cues", type: "Container" },0x3b: { name: "CuePoint", type: "Container" },0x33: { name: "CueTime", type: "Uint" },0x37: { name: "CueTrackPositions", type: "Container" },0x77: { name: "CueTrack", type: "Uint" },0x71: { name: "CueClusterPosition", type: "Uint" },0x70: { name: "CueRelativePosition", type: "Uint" },0x32: { name: "CueDuration", type: "Uint" },0x1378: { name: "CueBlockNumber", type: "Uint" },0x6a: { name: "CueCodecState", type: "Uint" },0x5b: { name: "CueReference", type: "Container" },0x16: { name: "CueRefTime", type: "Uint" },0x17: { name: "CueRefCluster", type: "Uint" },0x135f: { name: "CueRefNumber", type: "Uint" },0x6b: { name: "CueRefCodecState", type: "Uint" },0x941a469: { name: "Attachments", type: "Container" },0x21a7: { name: "AttachedFile", type: "Container" },0x67e: { name: "FileDescription", type: "String" },0x66e: { name: "FileName", type: "String" },0x660: { name: "FileMimeType", type: "String" },0x65c: { name: "FileData", type: "Binary" },0x6ae: { name: "FileUID", type: "Uint" },0x675: { name: "FileReferral", type: "Binary" },0x661: { name: "FileUsedStartTime", type: "Uint" },0x662: { name: "FileUsedEndTime", type: "Uint" },0x43a770: { name: "Chapters", type: "Container" },0x5b9: { name: "EditionEntry", type: "Container" },0x5bc: { name: "EditionUID", type: "Uint" },0x5bd: { name: "EditionFlagHidden", type: "Uint" },0x5db: { name: "EditionFlagDefault", type: "Uint" },0x5dd: { name: "EditionFlagOrdered", type: "Uint" },0x36: { name: "ChapterAtom", type: "Container" },0x33c4: { name: "ChapterUID", type: "Uint" },0x1654: { name: "ChapterStringUID", type: "String" },0x11: { name: "ChapterTimeStart", type: "Uint" },0x12: { name: "ChapterTimeEnd", type: "Uint" },0x18: { name: "ChapterFlagHidden", type: "Uint" },0x598: { name: "ChapterFlagEnabled", type: "Uint" },0x2e67: { name: "ChapterSegmentUID", type: "Binary" },0x2ebc: { name: "ChapterSegmentEditionUID", type: "Uint" },0x23c3: { name: "ChapterPhysicalEquiv", type: "Uint" },0xf: { name: "ChapterTrack", type: "Container" },0x9: { name: "ChapterTrackNumber", type: "Uint" },0x0: { name: "ChapterDisplay", type: "Container" },0x5: { name: "ChapString", type: "String" },0x37c: { name: "ChapLanguage", type: "String" },0x37e: { name: "ChapCountry", type: "String" },0x2944: { name: "ChapProcess", type: "Container" },0x2955: { name: "ChapProcessCodecID", type: "Uint" },0x50d: { name: "ChapProcessPrivate", type: "Binary" },0x2911: { name: "ChapProcessCommand", type: "Container" },0x2922: { name: "ChapProcessTime", type: "Uint" },0x2933: { name: "ChapProcessData", type: "Binary" },0x254c367: { name: "Tags", type: "Container" },0x3373: { name: "Tag", type: "Container" },0x23c0: { name: "Targets", type: "Container" },0x28ca: { name: "TargetTypeValue", type: "Uint" },0x23ca: { name: "TargetType", type: "String" },0x23c5: { name: "TagTrackUID", type: "Uint" },0x23c9: { name: "TagEditionUID", type: "Uint" },0x23c4: { name: "TagChapterUID", type: "Uint" },0x23c6: { name: "TagAttachmentUID", type: "Uint" },0x27c8: { name: "SimpleTag", type: "Container" },0x5a3: { name: "TagName", type: "String" },0x47a: { name: "TagLanguage", type: "String" },0x484: { name: "TagDefault", type: "Uint" },0x487: { name: "TagString", type: "String" },0x485: { name: "TagBinary", type: "Binary" },};function doInherit(newClass, baseClass) {newClass.prototype = Object.create(baseClass.prototype);newClass.prototype.constructor = newClass;}function WebmBase(name, type) {this.name = name || "Unknown";this.type = type || "Unknown";}WebmBase.prototype.updateBySource = function () {};WebmBase.prototype.setSource = function (source) {this.source = source;this.updateBySource();};WebmBase.prototype.updateByData = function () {};WebmBase.prototype.setData = function (data) {this.data = data;this.updateByData();};function WebmUint(name, type) {WebmBase.call(this, name, type || "Uint");}doInherit(WebmUint, WebmBase);function padHex(hex) {return hex.length % 2 === 1 ? "0" + hex : hex;}WebmUint.prototype.updateBySource = function () {// use hex representation of a number instead of number valuethis.data = "";for (var i = 0; i < this.source.length; i++) {var hex = this.source[i].toString(16);this.data += padHex(hex);}};WebmUint.prototype.updateByData = function () {var length = this.data.length / 2;this.source = new Uint8Array(length);for (var i = 0; i < length; i++) {var hex = this.data.substr(i * 2, 2);this.source[i] = parseInt(hex, 16);}};WebmUint.prototype.getValue = function () {return parseInt(this.data, 16);};WebmUint.prototype.setValue = function (value) {this.setData(padHex(value.toString(16)));};function WebmFloat(name, type) {WebmBase.call(this, name, type || "Float");}doInherit(WebmFloat, WebmBase);WebmFloat.prototype.getFloatArrayType = function () {return this.source && this.source.length === 4? Float32Array: Float64Array;};WebmFloat.prototype.updateBySource = function () {var byteArray = this.source.reverse();var floatArrayType = this.getFloatArrayType();var floatArray = new floatArrayType(byteArray.buffer);this.data = floatArray[0];};WebmFloat.prototype.updateByData = function () {var floatArrayType = this.getFloatArrayType();var floatArray = new floatArrayType([this.data]);var byteArray = new Uint8Array(floatArray.buffer);this.source = byteArray.reverse();};WebmFloat.prototype.getValue = function () {return this.data;};WebmFloat.prototype.setValue = function (value) {this.setData(value);};function WebmContainer(name, type) {WebmBase.call(this, name, type || "Container");}doInherit(WebmContainer, WebmBase);WebmContainer.prototype.readByte = function () {return this.source[this.offset++];};WebmContainer.prototype.readUint = function () {var firstByte = this.readByte();var bytes = 8 - firstByte.toString(2).length;var value = firstByte - (1 << (7 - bytes));for (var i = 0; i < bytes; i++) {// don't use bit operators to support x86value *= 256;value += this.readByte();}return value;};WebmContainer.prototype.updateBySource = function () {this.data = [];for (this.offset = 0; this.offset < this.source.length; this.offset = end) {var id = this.readUint();var len = this.readUint();var end = Math.min(this.offset + len, this.source.length);var data = this.source.slice(this.offset, end);var info = sections[id] || { name: "Unknown", type: "Unknown" };var ctr = WebmBase;switch (info.type) {case "Container":ctr = WebmContainer;break;case "Uint":ctr = WebmUint;break;case "Float":ctr = WebmFloat;break;}var section = new ctr(info.name, info.type);section.setSource(data);this.data.push({id: id,idHex: id.toString(16),data: section,});}};WebmContainer.prototype.writeUint = function (x, draft) {for (var bytes = 1, flag = 0x80;x >= flag && bytes < 8;bytes++, flag *= 0x80) {}if (!draft) {var value = flag + x;for (var i = bytes - 1; i >= 0; i--) {// don't use bit operators to support x86var c = value % 256;this.source[this.offset + i] = c;value = (value - c) / 256;}}this.offset += bytes;};WebmContainer.prototype.writeSections = function (draft) {this.offset = 0;for (var i = 0; i < this.data.length; i++) {var section = this.data[i],content = section.data.source,contentLength = content.length;this.writeUint(section.id, draft);this.writeUint(contentLength, draft);if (!draft) {this.source.set(content, this.offset);}this.offset += contentLength;}return this.offset;};WebmContainer.prototype.updateByData = function () {// run without accessing this.source to determine total length - need to know it to create Uint8Arrayvar length = this.writeSections("draft");this.source = new Uint8Array(length);// now really write datathis.writeSections();};WebmContainer.prototype.getSectionById = function (id) {for (var i = 0; i < this.data.length; i++) {var section = this.data[i];if (section.id === id) {return section.data;}}return null;};function WebmFile(source) {WebmContainer.call(this, "File", "File");this.setSource(source);}doInherit(WebmFile, WebmContainer);WebmFile.prototype.fixDuration = function (duration, options) {var logger = options && options.logger;if (logger === undefined) {logger = function (message) {console.log(message);};} else if (!logger) {logger = function () {};}var segmentSection = this.getSectionById(0x8538067);if (!segmentSection) {logger("[fix-webm-duration] Segment section is missing");return false;}var infoSection = segmentSection.getSectionById(0x549a966);if (!infoSection) {logger("[fix-webm-duration] Info section is missing");return false;}var timeScaleSection = infoSection.getSectionById(0xad7b1);if (!timeScaleSection) {logger("[fix-webm-duration] TimecodeScale section is missing");return false;}var durationSection = infoSection.getSectionById(0x489);if (durationSection) {if (durationSection.getValue() <= 0) {logger("[fix-webm-duration] Duration section is present, but the value is empty");durationSection.setValue(duration);} else {logger("[fix-webm-duration] Duration section is present");return false;}} else {logger("[fix-webm-duration] Duration section is missing");// append Duration sectiondurationSection = new WebmFloat("Duration", "Float");durationSection.setValue(duration);infoSection.data.push({id: 0x489,data: durationSection,});}// set default time scale to 1 millisecond (1000000 nanoseconds)timeScaleSection.setValue(1000000);infoSection.updateByData();segmentSection.updateByData();this.updateByData();return true;};WebmFile.prototype.toBlob = function (mimeType) {return new Blob([this.source.buffer], { type: mimeType || "video/webm" });};function fixWebmDuration(blob, duration, callback, options) {// The callback may be omitted - then the third argument is optionsif (typeof callback === "object") {options = callback;callback = undefined;}if (!callback) {return new Promise(function (resolve) {fixWebmDuration(blob, duration, resolve, options);});}try {var reader = new FileReader();reader.onloadend = function () {try {var file = new WebmFile(new Uint8Array(reader.result));if (file.fixDuration(duration, options)) {blob = file.toBlob(blob.type);}} catch (ex) {// ignore}callback(blob);};reader.readAsArrayBuffer(blob);} catch (ex) {callback(blob);}}// Support AMD import defaultfixWebmDuration.default = fixWebmDuration;return fixWebmDuration;
});
多对多 监控页面
<!DOCTYPE>
<html><head><meta charset="UTF-8"><title>WebRTC + WebSocket</title><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><style>html,body {margin: 0;padding: 0;background: #222;}#main {position: absolute;width: 100%;height: 100%;}video {margin: 5px;width: 300px;}#buttons {z-index: 3;bottom: 20px;left: 20px;position: absolute;}input {border: 1px solid #ccc;padding: 7px 0px;border-radius: 5px;padding-left: 5px;margin-bottom: 5px;}input :focus {border-color: #66afe9;outline: 0;-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px rgba(102, 175, 233, .6);box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px rgba(102, 175, 233, .6)}#call {width: 70px;height: 35px;background-color: #00BB00;border: none;color: white;border-radius: 5px;}#hangup {width: 70px;height: 35px;background-color: #FF5151;border: none;color: white;border-radius: 5px;}button {width: 70px;height: 35px;margin: 6px;background-color: #6de73d;border: none;color: white;border-radius: 5px;}</style>
</head><body><div id="main"><div id="buttons"><input id="myid" readonly /><br /><input id="toUser" placeholder="用户1" /><br /><button id="call" onclick="getStream()">获取流</button></div></div>
</body>
<!-- 可引可不引 -->
<!--<script th:src="@{/js/adapter-2021.js}"></script>-->
<script type="text/javascript" th:inline="javascript">function generateRandomLetters(length) {let result = '';const characters = 'abcdefghijklmnopqrstuvwxyz'; // 字母表for (let i = 0; i < length; i++) {const randomIndex = Math.floor(Math.random() * characters.length);const randomLetter = characters[randomIndex];result += randomLetter;}return result;}let username = generateRandomLetters(3);document.getElementById('myid').value = username;let websocket = null;let peer = {};let candidate = null;/* WebSocket */function WebSocketInit() {//判断当前浏览器是否支持WebSocketif ('WebSocket' in window) {websocket = new WebSocket("ws://192.168.31.14:8181/webrtc/" + username);} else {alert("当前浏览器不支持WebSocket!");}//连接发生错误的回调方法websocket.onerror = function (e) {alert("WebSocket连接发生错误!");};//连接关闭的回调方法websocket.onclose = function () {console.error("WebSocket连接关闭");};//连接成功建立的回调方法websocket.onopen = function () {console.log("WebSocket连接成功");};//接收到消息的回调方法websocket.onmessage = async function (event) {let { type, fromUser, toUser, msg, sdp, iceCandidate } = JSON.parse(event.data.replace(/
/g, "
").replace(/
/g, “\r”));
console.log(type, fromUser, toUser);if (type === 'call_back') {if (msg === "1") {} else if (msg === "0") {} else {peer[fromUser].close();document.getElementById(fromUser).remove()alert(msg);}return;}if (type === 'offer') {// let stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })// localVideo.srcObject = stream;// stream.getTracks().forEach(track => {// peer[fromUser].addTrack(track, stream);// });console.log("管理员收到", fromUser, peer)await peer[fromUser].setRemoteDescription(new RTCSessionDescription({ type, sdp }));let answer = await peer[fromUser].createAnswer();newAnswer ={...answer}newAnswer["fromUser"] = username;newAnswer["toUser"] = fromUser;websocket.send(JSON.stringify(newAnswer));await peer[fromUser].setLocalDescription(newAnswer);return;}if (type === 'answer') {peer[fromUser].setRemoteDescription(new RTCSessionDescription({ type, sdp }));return;}if (type === '_ice') {peer[fromUser].addIceCandidate(iceCandidate);return;}}}WebSocketInit();/* WebRTC */function WebRTCInit(userId) {if (peer[userId]) {peer[userId].close();}const p = new RTCPeerConnection();//icep.onicecandidate = function (e) {if (e.candidate) {websocket.send(JSON.stringify({type: '_ice',toUser: userId,fromUser: username,iceCandidate: e.candidate}));}};// 创建video元素const videoElement = document.createElement('video');videoElement.id = userId;videoElement.setAttribute('playsinline', '');videoElement.setAttribute('autoplay', '');videoElement.setAttribute('controls', '');// 将video元素添加到DOM中document.body.appendChild(videoElement);//trackp.ontrack = function (e) {if (e && e.streams) {console.log(e);videoElement.srcObject = e.streams[0];}};peer[userId] = p;}async function getStream() {let toUser = document.getElementById('toUser').value;WebRTCInit(toUser);websocket.send(JSON.stringify({type: 'getStream',toUser: toUser,fromUser: username}));// let offer = await peer[toUser].createOffer();// await peer[toUser].setLocalDescription(offer);// let newOffer = offer.toJSON();// newOffer["fromUser"] = username;// newOffer["toUser"] = toUser;// websocket.send(JSON.stringify(newOffer));}</script></html>
websocket java代码
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.websocket.*;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;import java.text.SimpleDateFormat;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;/*** WebRTC + WebSocket*/
@Slf4j
@Component
@ServerEndpoint(value = "/webrtc/{username}")
public class WebRtcWSServer {/*** 连接集合*/private static final Map<String, Session> sessionMap = new ConcurrentHashMap<>();/*** 连接建立成功调用的方法*/@OnOpenpublic void onOpen(Session session, @PathParam("username") String username, @PathParam("publicKey") String publicKey) {sessionMap.put(username, session);}/*** 连接关闭调用的方法*/@OnClosepublic void onClose(Session session) {for (Map.Entry<String, Session> entry : sessionMap.entrySet()) {if (entry.getValue() == session) {sessionMap.remove(entry.getKey());break;}}}/*** 发生错误时调用*/@OnErrorpublic void onError(Session session, Throwable error) {error.printStackTrace();}/*** 服务器接收到客户端消息时调用的方法*/@OnMessagepublic void onMessage(String message, Session session) {try{//jacksonObjectMapper mapper = new ObjectMapper();mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);//JSON字符串转 HashMapHashMap hashMap = mapper.readValue(message, HashMap.class);//消息类型String type = (String) hashMap.get("type");//to userString toUser = (String) hashMap.get("toUser");Session toUserSession = sessionMap.get(toUser);String fromUser = (String) hashMap.get("fromUser");//msgString msg = (String) hashMap.get("msg");//sdpString sdp = (String) hashMap.get("sdp");//iceMap iceCandidate = (Map) hashMap.get("iceCandidate");HashMap<String, Object> map = new HashMap<>();map.put("type",type);//呼叫的用户不在线if(toUserSession == null){toUserSession = session;map.put("type","call_back");map.put("fromUser",toUser);map.put("msg","Sorry,呼叫的用户不在线!");send(toUserSession,mapper.writeValueAsString(map));return;}map.put("fromUser",fromUser);map.put("toUser",toUser);//对方挂断if ("hangup".equals(type)) {map.put("fromUser",fromUser);map.put("msg","对方挂断!");}//视频通话请求if ("call_start".equals(type)) {map.put("fromUser",fromUser);map.put("msg","1");}//视频通话请求回应if ("call_back".equals(type)) {map.put("msg",msg);}//offerif ("offer".equals(type)) {map.put("fromUser",fromUser);map.put("toUser",toUser);map.put("sdp",sdp);}//answerif ("answer".equals(type)) {map.put("fromUser",fromUser);map.put("toUser",toUser);map.put("sdp",sdp);}//iceif ("_ice".equals(type)) {map.put("iceCandidate",iceCandidate);}// getStreamif ("getStream".equals(type)) {map.put("fromUser",fromUser);map.put("toUser",toUser);}send(toUserSession,mapper.writeValueAsString(map));}catch(Exception e){e.printStackTrace();}}/*** 封装一个send方法,发送消息到前端*/private void send(Session session, String message) {try {System.out.println(message);session.getBasicRemote().sendText(message);} catch (Exception e) {e.printStackTrace();}}
}
springboot 相关依赖和配置
// 1.pom
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId>
</dependency>// 2.启用功能
@EnableWebSocket
public class CocoBootApplication3.config
package com.coco.boot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;@Configuration
public class WebSocketConfig {@Beanpublic ServerEndpointExporter serverEndpointExporter() {return new ServerEndpointExporter();}
}
完结
实现多人通信,通过创建多个 RTCPeerConnection 对象来实现。
每个参与者都需要与其他参与者建立连接。每个参与者都维护一个 RTCPeerConnection 对象,分别与其他参与者进行连接。
参与者数量的增加,点对点连接的复杂性和网络带宽要求也会增加。
WebRTC 建立连接时,以下顺序执行:
1.创建本地 PeerConnection 对象:使用 RTCPeerConnection 构造函数创建本地的 PeerConnection 对象,该对象用于管理 WebRTC 连接。
2.添加本地媒体流:通过调用 getUserMedia 方法获取本地的音视频流,并将其添加到 PeerConnection 对象中。这样可以将本地的音视频数据发送给远程对等方。
3.创建和设置本地 SDP:使用 createOffer 方法创建本地的 Session Description Protocol (SDP),描述本地对等方的音视频设置和网络信息。然后,通过调用 setLocalDescription 方法将本地 SDP 设置为本地 PeerConnection 对象的本地描述。
4.发送本地 SDP:将本地 SDP 发送给远程对等方,可以使用信令服务器或其他通信方式发送。
5.接收远程 SDP:从远程对等方接收远程 SDP,可以通过信令服务器或其他通信方式接收。
6.设置远程 SDP:使用接收到的远程 SDP,调用 PeerConnection 对象的 setRemoteDescription 方法将其设置为远程描述。
7.创建和设置本地 ICE 候选项:使用 onicecandidate 事件监听 PeerConnection 对象的 ICE 候选项生成,在生成候选项后,通过信令服务器或其他通信方式将其发送给远程对等方。
8.接收和添加远程 ICE 候选项:从远程对等方接收到 ICE 候选项后,调用 addIceCandidate 方法将其添加到本地 PeerConnection 对象中。
9.连接建立:一旦本地和远程的 SDP 和 ICE 候选项都设置好并添加完毕,连接就会建立起来。此时,音视频流可以在本地和远程对等方之间进行传输。
主要参考:
WebRTC + WebSocket 实现视频通话
WebRTC穿透服务器防火墙配置问题
WebRTC音视频录制
WebRTC 多人视频聊天
相关文章:
websocket 局域网 webrtc 一对一 多对多 视频通话 的示例
基本介绍 WebRTC(Web Real-Time Communications)是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和&am…...
uniapp-微信小程序调用摄像头
1.uniapp中的index.vue代码 <template><view class"content"><view class"container"><!-- 摄像头组件 --><camera id"camera" device-position"front" flash"off" binderror"onCameraErr…...
鸿蒙学习笔记:用户登录界面
文章目录 1. 提出任务2. 完成任务2.1 创建鸿蒙项目2.2 准备图片资源2.3 编写首页代码2.4 启动应用 3. 实战小结 1. 提出任务 本次任务聚焦于运用 ArkUI 打造用户登录界面。需呈现特定元素:一张图片增添视觉感,两个分别用于账号与密码的文本输入框&#…...
无人机航测系统技术特点!
一、无人机航测系统的设计逻辑 无人机航测系统的设计逻辑主要围绕实现高效、准确、安全的航空摄影测量展开。其设计目标是通过无人机搭载相机和传感器,利用先进的飞行控制系统和数据处理技术,实现对地表信息的全方位、高精度获取。 需求分析࿱…...
《算法ZUC》题目
判断题 ZUC算法LFSR部分产生的二元序列具有很低的线性复杂度。 A.正确 B.错误 正确答案A 单项选择题 ZUC算法驱动部分LFSR的抽头位置不包括( )。 A.s15 B.s10 C.s7 D.s0 正确答案C 单项选择题 ZUC算法比特重组BR层主要使用了软件实现友好的…...
配置flutter 解决andriod studio报错 no device selected
flutter配置好后 明明下载好了模拟器 但是在andriod studio 找不到设备 显示no devices 这个时候需要我们配置一下flutter关联的android sdk的路径和文件夹 就可以解决了 flutter config --android-sdk 自己android studio的路径 这样配置就可以解决了~...
docker搭建Redis集群及哨兵(windows10环境,OSS Cluster)
一、基本概念 Redis:即 "Remote DIctionary Server" ,翻译为“远程字典服务器”。从字面意义上讲,它指的是一个远程的字典服务,意味着它是一个可以远程访问的服务,主要用于存储键值对(key-value pairs&…...
信息化基础知识——数字政府(山东省大数据职称考试)
大数据分析应用-初级 第一部分 基础知识 一、大数据法律法规、政策文件、相关标准 二、计算机基础知识 三、信息化基础知识 四、密码学 五、大数据安全 六、数据库系统 七、数据仓库. 第二部分 专业知识 一、大数据技术与应用 二、大数据分析模型 三、数据科学 数字政府 大数…...
信息安全实训室网络攻防靶场实战核心平台解决方案
一、引言 网络安全靶场,作为一种融合了虚拟与现实环境的综合性平台,专为基础设施、应用程序及物理系统等目标设计,旨在向系统用户提供全方位的安全服务,涵盖教学、研究、训练及测试等多个维度。随着网络空间对抗态势的日益复杂化…...
Nginx主要知识点总结
1下载nginx 到nginx官网nginx: download下载nginx,然后解压压缩包 然后双击nginx.exe就可以启动nginx 2启动nginx 然后在浏览器的网址处输入localhost,进入如下页面说明nginx启动成功 3了解nginx的配置文件 4熟悉nginx的基本配置和常用操作 Nginx 常…...
PySide6程序框架设计
pyside6有一个优点自动适配高分辨ui pyqt5需要自己写这部分逻辑 1、主程序代码 DINGSHI01Main.py # -*- coding: utf-8 -*- import sys,time,copy from PySide6.QtWidgets import QWidget,QApplication from PySide6.QtCore import Qt from PySide6 import QtCore, QtGui, Q…...
「九」HarmonyOS 5 端云一体化实战项目——「M.U.」应用云侧开发云数据库
1 立意背景 M. 代表 “我”,U. 代表 “你”,这是一款用于记录情侣从相识、相知、相恋、见家长、订婚直至结婚等各个阶段美好记忆留存的应用程序。它旨在为情侣们提供一个专属的空间,让他们能够将一路走来的点点滴滴,如初次相遇时…...
记录:virt-manager配置Ubuntu arm虚拟机
virt-manager(Virtual Machine Manager)是一个图形用户界面应用程序,通过libvirt管理虚拟机(即作为libvirt的图形前端) 因为要在Linux arm环境做测试,记录下virt-manager配置arm虚拟机的过程 先在VMWare中…...
clickhouse-介绍、安装、数据类型、sql
1、介绍 ClickHouse是俄罗斯的Yandex于2016年开源的列式存储数据库(DBMS),使用C语言编写,主要用于在线分析处理查询(OLAP),能够使用SQL查询实时生成分析数据报告。 OLAP(On-Line A…...
【shell】常用100个shell命令使用讲解
【shell】常用100个shell命令使用讲解 【一】文件操作命令【二】搜索命令【三】目录操作命令【四】权限操作命令【五】网络操作命令【六】进程和系统控制命令【七】文本操作命令【八】压缩与解压命令【九】磁盘使用管理命令【十】包管理命令【十一】进程管理命令【十二】环境变…...
Git-分支(branch)常用命令
分支 我们在做项目开发的时候,无论是软件项目还是其他机械工程项目,我们为了提高效率以及合理的节省时间等等原因,现在都不再是线性进行,而是将一个项目抽离出诸进行线,每一条线在git中我们就叫做分支,bran…...
谈谈es6 Map 函数
发现宝藏 前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。【宝藏入口】。 Map 是 ES6 中引入的一种新的数据结构,它类似于对象(Object),但与对象相比&#…...
微信小程序:实现节点进度条的效果;正在完成的节点有动态循环效果;横向,纵向排列
参考说明 微信小程序实现流程进度功能 - 知乎 上面的为一个节点进度条的例子,但并不完整,根据上述代码,进行修改完善,实现其效果 横向效果 代码 wxml <view classorder_process><view classprocess_wrap wx:for&quo…...
【Unity3D】无限循环列表(扩展版)
基础版:【Unity技术分享】UGUI之ScrollRect优化_ugui scrollrect 优化-CSDN博客 using UnityEngine; using UnityEngine.UI; using System.Collections.Generic;public delegate void OnBaseLoopListItemCallback(GameObject cell, int index); public class BaseLo…...
MacOS 命令行详解使用教程
本章讲述MacOs命令行详解的使用教程,感谢大家观看。 本人博客:如烟花般绚烂却又稍纵即逝的主页 MacOs命令行前言: 在 macOS 上,Terminal(终端) 是一个功能强大的工具,它允许用户通过命令行直接与系统交互。本教程将详细介绍 macOS…...
STM32F407移植QP状态机踩坑实录:从编译报错到成功运行,我解决了这三个关键问题
STM32F407移植QP状态机踩坑实录:从编译报错到成功运行,我解决了这三个关键问题 在嵌入式开发中,状态机是一种极其重要的编程范式,它能有效管理复杂系统的行为逻辑。QP(Quantum Platform)作为一款轻量级的状…...
IDEA 2018.2.3 下 Maven 依赖包消失?别慌,可能是版本兼容性在作祟
IDEA 2018.2.3 下 Maven 依赖包消失的深度排查指南 当你打开一个尘封已久的老项目,准备继续维护或迁移时,突然发现IDEA的External Libraries里空空如也,只剩下孤零零的JDK包,整个项目文件一片飘红——这种场景对许多维护历史代码库…...
纯视觉纵深无感管控,落地硐室无人少人化透明值守模式技术白皮书
纯视觉纵深无感管控,落地硐室无人少人化透明值守模式技术白皮书副标题:摒弃井下繁杂传感布设,依靠暗光三维实景重构、深部空间无感感知、盲区跨镜无痕跟踪、身体指纹生物核验,实现井下 24 小时无人值守、全域透明运维前言矿山井下…...
qmcdump:专业解决QQ音乐加密音频格式兼容性问题
qmcdump:专业解决QQ音乐加密音频格式兼容性问题 【免费下载链接】qmcdump 一个简单的QQ音乐解码(qmcflac/qmc0/qmc3 转 flac/mp3),仅为个人学习参考用。 项目地址: https://gitcode.com/gh_mirrors/qm/qmcdump 在数字音乐时…...
Obsidian智能模板终极指南:3步打造高效笔记自动化系统
Obsidian智能模板终极指南:3步打造高效笔记自动化系统 【免费下载链接】Templater A template plugin for obsidian 项目地址: https://gitcode.com/gh_mirrors/te/Templater Templater插件是Obsidian生态系统中功能最强大的智能模板解决方案,它能…...
Arm CoreLink PCK-600电源管理架构与寄存器编程详解
1. Arm CoreLink PCK-600电源控制架构解析在嵌入式系统设计中,电源管理单元(PMU)是实现高效能耗控制的核心组件。Arm CoreLink PCK-600作为业界领先的电源控制解决方案,其架构设计体现了现代SoC电源管理的先进理念。PCK-600系列采…...
怎么找到一个行业的源头工厂、绕开中间商?一套五步识别流程
你下了单,货到了,质量也还行。但心里一直有个疙瘩:这家供应商到底是自己在生产,还是从别处转手赚了你一道差价? 这个问题对采购方和跨境卖家不是洁癖,是真金白银。同一款产品,源头工厂和中间商的…...
Python数据聚合抓取工具:从配置化引擎到实战避坑指南
1. 项目概述:一个多功能的“聚合爪”工具最近在GitHub上闲逛,发现了一个名字挺有意思的项目:al1enjesus/polyclawster。这个名字拆开看,“poly”代表多,“clawster”听起来像是“claw”(爪子)和…...
柔性3D打印与生物仿生设计:从TPU材料到空气喷涂的完整实践
1. 项目概述:当柔性3D打印遇上生物仿生美学如果你和我一样,玩3D打印玩久了,总会对那些千篇一律的硬质塑料件感到一丝审美疲劳。我们总在追求更高的精度、更强的结构,却常常忽略了材料本身可以带来的、截然不同的体验。直到我开始接…...
基于容器技术的在线代码沙盒:架构设计与安全实践
1. 项目概述:一个开箱即用的在线代码运行沙盒最近在折腾一些需要快速验证代码片段、或者给团队做技术分享的场景,我发现一个痛点:环境配置太麻烦了。你想让新人跑个Python脚本,他可能得先装Python、配环境变量、装依赖库ÿ…...
