Vue3+Node中使用webrtc推流至mediamtx

前言

项目的 Web 端是 Vue3 框架,后端是 GO 框架。需要实现将客户端的本地摄像头媒体流推送至服务端,而我自己从未有媒体流相关经验,最初 leader 让我尝试通过 RTSP 协议推拉流,我的思路就局限在了 RTSP 方向。

最初使用的服务端流媒体处理服务器是RTSPToWeb

GitHub - deepch/RTSPtoWeb:RTSP 流到 WebBrowser

RTSPtoWeb 可以将RTSP 流转换为可在 Web 浏览器中使用的格式,如 MSE(媒体源扩展)、WebRTC 或 HLS。

我打算在 Web 端将本地摄像头数据流以RTSP协议发送至服务端,通过RTSPtoWeb处理为Web可以使用的格式。客户端的推流软件我选择FFmpeg,我找到了可以在Vue中使用FFmpeg的方法:

FFmpeg——在Vue项目中使用FFmpeg(安装、配置、使用、SharedArrayBuffer、跨域隔离、避坑…)_vue ffmpeg-CSDN博客

在浏览器中我们是无法直接使用 FFmpeg 软件的,但好在有个东西叫FFmpeg.wasm,它可以让 FFmpeg 的功能在浏览器中使用。我们在 Vue 项目中使用 FFmpeg.wasm来代替手动输入命令行操作的 FFmpeg 软件。FFmpeg.wasm 是 FFmpeg 的纯 WebAssembly 接口,可以在浏览器内录制音频和视频,并进行转换和流式传输。但后面实际操作我发现,现在FFmpeg.wasm在0.12.0版本之后不再支持 NodeJS

FAQ | ffmpeg.wasm (ffmpegwasm.netlify.app)

但使用 FFmpeg.wasm 旧版本时我遇到好多报错。。。我第一次写前端能力属实不足,最后选择放弃了这条思路。。。有能力或者使用的不是 NodeJS 的小伙伴可以用 FFmpeg.wasm 在 Web 推流,很方便好用。

后面我又有一个歪点子,用 GO 编写从命令行端操作 FFmpeg 推拉流 API ,再打包为 exe 可执行文件,运行在客户端。但在小组开会后,这个方案被毙了。。。因为没有考虑客户需求,首先客户在 PC 端访问我们的 Web ,不仅需要下载 FFmpeg ,现在还得多下载一个 exe 文件;其次是考虑客户要在移动端使用。第一次实习,第一次做客户项目,考虑的没有很全面。

后面我发现为什么不直接用WebRTC呢?这可是专门用来解决Web媒体流的好东西!!!

于是我更改了方案,将mediamtx作为新的服务器,

GitHub - bluenviron/mediamtx: Ready-to-use SRT / WebRTC / RTSP / RTMP / LL-HLS media server and media proxy that allows to read, publish, proxy, record and playback video and audio streams.

mediamtx支持多种协议,可以解决很多需求,强推!!!

WebRTC简介

搞懂WebRTC ,看这一篇就够了-CSDN博客

WebRTC提供了基础的前端功能实现,仅仅通过JavaScript,Web端即可实现点对点的视频流、音频流或者其他数据的传输,所用到的知识点如下:

WHIP /WHEP 协议

WTN普及(一)WHIP/WHEP标准信令 - 知乎 (zhihu.com)

WebRTC(WebRTC-HTTP Ingestion Protocol)通过 WHIP 协议将音视频流从客户端传输到服务器。WHEP(WebRTC-HTTPEgressProtocol)允许基于浏览器的流媒体内容的低延迟观看。

WHIP /WHEP 不仅仅可以用作流媒体的传输。在未建立 WebRTC 之前,通讯双方需要商议彼此的媒体协议,也可能无法访问彼此的 IP,故我们需要信令服务器传递双方的 SDP 和 candidates 信息,而俩个协议在 WebRTC 之上增加了一个简单的信令层解决了这个问题,我们可以通过 WebStock 或者 http 向信令层发送信息。

SDP 协议

WebRTC通话原理(SDP、STUN、 TURN、 信令服务器)_webrtc stun服务器-CSDN博客

通信双方需要发送媒体流,而视频和音频都涉及到编码格式,故双方需要先协商统一编码格式,保证媒体流顺利发送。

SDP(Session Description Protocol)是一种用于描述多媒体会话的格式。它包含了会话的媒体类型、格式、传输协议和网络信息等。SDP 在 WebRTC 中用于协商音视频通话的各种参数,确保两个端点可以兼容并顺利进行通信。

NAT 穿透

NAT(Network Address Translation,网络地址交换)主要解决 IPv4 地址不够用和安全问题。通过多台主机共用一个公网 IP 地址来减缓 IPv4 地址不够用的问题。使用 NAT 后,主机隐藏在内网,这样黑客很难访问到内网主机,从而达到保护内网主机的目的。NAT 其实就是一种地址映射技术,它在内网地址与外网地址之间建立了映射关系。

通讯双方不在一个局域网内,则无法访问直接彼此的 IP,故需要 NAT 将双方的内网 IP 转换为 公网 IP,以便于双方可以互相访问。为实现穿透,我们需要用到 ICE(Interactive Connectivity Establishment,交互式连接创建)建立双方的网络连接。

ICE

WebRTC技术文档 – 5.ICE(笔记)_webrtc ice-CSDN博客

ICE 是一种基于 offer/answer 模式解决 NAT 穿越的协议集合。它结合STUN和TURN协议,使客户端无需考虑网络位置和NAT类型即可动态发现最优传输路径。

实现的具体过程为:收集网络信息 Candidate、交换 Candidate、按优先级尝试连接。Candidate指可连接的候选者。每个候选者是包含address(IP地址)、port(端口号)、protocol(传输协议)、CandidateType(Candidate类型)、ufrag(用户名)等内容的信息集。WebRTC将Candidate分为host、srflx、prflx和relay四类,优先级依次由高到低。

STUN / TRUN

WebRTC学习之路—TURN/STUN服务原理及搭建_webrtc 客户端建立连接 stun-CSDN博客

ICE 使用 STUN Binding Request 和 Response,来获取公网映射地址和进行连通性检查。客户端向 STUN 服务器发送请求,STUN 服务器返回其看到的客户端的公共地址和端口。这样,客户就可以告诉其他对等方(Peer)自己的公共地址,以便建立直接连接。
ICE 使用 TURN 协议作为 STUN 的辅助,在点对点穿越失败的情况下,借助于 TURN 服务的转发功能,来实现互通。客户端首先尝试使用 STUN 获取公共地址。如果双方无法通过公共地址直接连接,客户端可以将媒体发送到 TURN 服务器,由 TURN 服务器转发到对等方。这种方式虽然增加了延迟,但可以保证连接的建立。

WebRTC 流程图

WebRTC的建立如下图:

在下面代码中,Web端(client)与远端(mediamtx 服务器)通过HTTP 请求进行交互实现信令。

具体实现

安装运行mediamtx

mediamtx 我们只需要直接下载独立二进制文件运行即可。

下载地址:Releases · bluenviron/mediamtx (github.com)

windows 系统下载圈出来的即可,解压后里面有一个 exe 文件,打开即可

通过WebRTC发送媒体流的示例网址

注意:以下项目和mediamtx 都运行在一个 PC 上

mediamtx提供了一个发送媒体流的示例网址的源代码:

mediamtx/internal/servers/webrtc/publish_index.html at main · bluenviron/mediamtx · GitHub

URL:localhost:8889/1/publish

其中1代表的是路径,也是后面查询媒体流和保存媒体流的路径,示例页面如下:

我们看到video device为OBS,数据流的默认选项是OBS虚拟摄像头,当有外部设备接入,如USB摄像头,会默认选择为 USB 摄像头设备。video device 还可选择 screen ,即本地屏幕推流。

其他的选项依次是视频的编码、波特率、帧率、分辨率和音频的设备、编码、波特率、优化

我接入设备后,选项都是默认的 publish 画面如下:

mediamtx 的info信息为:

WebRTC 创建新的 session

对等连接(peer connection)成功建立;本地(Web)候选地址和远端(mediamtx)候选地址

[path 1] 代表录制的路径,这里会录制是因为我在mediamtx.yaml 文件中配置了录制,其他配置还要保存路径、格式、最大录制时间、录制片段时间和自动删除时间

正在录制音视频轨道,Opus 格式的音频轨道AV1 格式的视频轨道。

Vue3中实现WebRTC发送媒体流

根据示例网址的源代码,我们可以修改 WebRTC 代码格式如下:

HTML 元素:

<template>
    <div>
      <video ref="videoElement" autoplay playsinline></video>
    </div>
</template>

导包和定义的参数:

import { ref } from 'vue';

// 其中1为路径
// whip 用于身份验证
const webrtcUrl = http://localhost:8889/1/whip;   // 1代表路径,可改为你自己的路径
const retryPause = 2000;
const videoElement = ref<HTMLVideoElement | null>(null);

let pc: any = null;
let stream: any = null;
let restartTimeout: number | null = null;
let sessionUrl = '';
let offerData: OfferDescription;
let queuedCandidates: RTCIceCandidate[] = [];

interface OfferDescription {
  iceUfrag: string; // 唯一标识 sdp 的短字符串
  icePwd: string; // sdp 对应密码
  medias: any[]; // 媒体描述,编码率等信息
}

主函数:

const onPublish = () => {
  postMessage('connecting');
  const videoId = videoForm.device;
  const audioId = audioForm.device;
  let videoOpts: { deviceId: string } | boolean = false;

  let audioOpts = {
    deviceId: '',
    autoGainControl: true, //自动增益控制
    echoCancellation: true, //启用回声消除
    noiseSuppression: true, //噪音抑制
  };

  if (videoId !== 'screen') {
    if (videoId !== 'none') {
      videoOpts = {
        deviceId: videoId,
      };
    }

    if (audioId !== 'none') {
      audioOpts.deviceId = audioId;

      const voice = audioForm.voice;
      if (!voice) {
        // 如果没有声音选择,则关闭声音
        audioOpts.autoGainControl = false;
        audioOpts.echoCancellation = false;
        audioOpts.noiseSuppression = false;
      }
    }

    navigator.mediaDevices
      .getUserMedia({
        video: videoOpts,
        audio: audioOpts,
      })
      .then((str) => {
        stream = str;
        if (videoElement.value) {
          //将得到的媒体流赋予videoElement,显示在 HTML 元素中
          videoElement.value.srcObject = stream;    
        }
        requestICEServers();
      })
      .catch((err) => {
        onError(err.toString(), false);
      });
  } else {
    navigator.mediaDevices
      .getDisplayMedia({
        video: {
          width: { ideal: parseInt(videoForm.width) },
          height: { ideal: parseInt(videoForm.height) },
          frameRate: { ideal: parseInt(videoForm.framerate) },
        },
        audio: true,
      })
      .then((str) => {
        stream = str;
        if (videoElement.value) {
          videoElement.value.srcObject = stream;
        }
        requestICEServers();
      })
      .catch((err) => {
        onError(err.toString(), false);
      });
  }
};

Web 端获取STUN 服务器,收集本地网络信息(candidate),通过 ICE 服务器获取 Web 端的公网ip,并添加至 candidate

const requestICEServers = () => {
  //请求 STUN 服务器
  fetch(webrtcUrl.value, {
    method: 'OPTIONS',
  })
    .then((res) => {
      // 通过返回值中的头获取 STUN 服务器信息
      // STUN 服务器信息在yaml文件中设置
      // 我在mediamtx.yaml设置 STUN 为 url: stun:stun.l.google.com:19302
      pc = new RTCPeerConnection({
        iceServers: linkToIceServers(res.headers.get('Link')),
      });

      pc.onicecandidate = (evt: RTCPeerConnectionIceEvent) =>
        onLocalCandidate(evt);
      pc.oniceconnectionstatechange = () => onConnectionState();
      stream.getTracks().forEach((track: any) => {
        pc.addTrack(track, stream);
      });
      createOffer();
    })
    .catch((err) => {
      onError(err.toString(), true);
    });
};

const linkToIceServers = (links: any): any => {
  if (links === null) return []; // 检查 `links` 是否为 null
  return links
    .split(', ')
    .map((link: any) => {
      const m = link.match(
        /^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i
      );

      if (!m) return null; // 如果没有匹配,返回 null

      const ret = {
        urls: [m[1]],
      } as {
        urls: any[];
        username?: string;
        credential?: string;
        credentialType?: string;
      };

      if (m[3] !== undefined) {
        ret.username = unquoteCredential(m[3]);
        ret.credential = unquoteCredential(m[4]);
        ret.credentialType = 'password';
      }

      return ret; // 始终返回 ret
    })
    .filter(Boolean); // 筛选掉 null 值
};

// 带有引号的凭证字符串解析为 JSON 格式
const unquoteCredential = (v: string) => JSON.parse(`"${v}"`);

// 监听并收集本地的网络信息 candidate
const onLocalCandidate = (evt: any) => {
  if (restartTimeout !== null) {
    return;
  }
  // 检测到新的 candidate
  if (evt.candidate !== null) {
    // 代表尚未建立连接
    if (sessionUrl === '') {
      // 将 candidate 加入队列
      queuedCandidates.push(evt.candidate);
    } else {
      sendLocalCandidates([evt.candidate]);
    }
  }
};

// 发送 SDP 主要信息和网络信息 candidate 完成WebRTC 建立
const sendLocalCandidates = async (candidates: any) => {
  await fetch(sessionUrl, {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/trickle-ice-sdpfrag',
      'If-Match': '*',
    },
    body: generateSdpFragment(offerData, candidates),
  })
    .then((res) => {
      if (res.status !== 204) {
        throw new Error(`bad status code ${res.status}`);
      }
    })
    .catch((err) => {
      onError(err.toString(), true);
    });
};

// 使用 SDP 主要信息和网络信息 candidate生成片段
const generateSdpFragment = (od: any, candidates: any) => {
  const candidatesByMedia: any = {};
  for (const candidate of candidates) {
    const mid = candidate.sdpMLineIndex;
    if (candidatesByMedia[mid] === undefined) {
      candidatesByMedia[mid] = [];
    }
    candidatesByMedia[mid].push(candidate);
  }

  let frag =
    'a=ice-ufrag:' + od.iceUfrag + '
' + 'a=ice-pwd:' + od.icePwd + '
';

  let mid = 0;

  for (const media of od.medias) {
    if (candidatesByMedia[mid] !== undefined) {
      frag += 'm=' + media + '
' + 'a=mid:' + mid + '
';

      for (const candidate of candidatesByMedia[mid]) {
        frag += 'a=' + candidate.candidate + '
';
      }
    }
    mid++;
  }

  return frag;
};

Web 端和远端(mediamtx)交换 SDP

// 创建 SDP ,描述本端浏览器支持哪些能力
const createOffer = () => {
  pc.createOffer()
    .then((offer: any) => {
      offerData = parseOffer(offer.sdp);
      if (pc) {
        // offer 设置为本地描述
        pc.setLocalDescription(offer)
          .then(() => {
            sendOffer(offer.sdp);
          })
          .catch((err: any) => {
            onError(err.toString());
          });
      }
    })
    .catch((err: any) => {
      onError(err.toString());
    });
};

// 解析 SDP ,得到 SDP 中的主要信息
const parseOffer = (offer: any) => {
  const ret: OfferDescription = {
    iceUfrag: '',
    icePwd: '',
    medias: [],
  };

  for (const line of offer.split('
')) {
    if (line.startsWith('m=')) {
      ret.medias.push(line.slice('m='.length));
    } else if (ret.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) {
      ret.iceUfrag = line.slice('a=ice-ufrag:'.length);
    } else if (ret.icePwd === '' && line.startsWith('a=ice-pwd:')) {
      ret.icePwd = line.slice('a=ice-pwd:'.length);
    }
  }

  return ret;
};

// 发送 SDP 到远端(mediamtx)
const sendOffer = async (offer: any) => {
  console.log('sendOffer', offer);
  offer = editOffer(offer);
  await fetch(
    webrtcUrl.value + `?video-device=${videoForm.device}`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/sdp',
      },
      body: offer,
    }
  )
    .then((res) => {
      switch (res.status) {
        case 201:
          break;
        case 400:
          return res.json().then((e) => {
            throw new Error(e.error);
          });
        default:
          throw new Error(`bad status code ${res.status}`);
      }

      const locationHeader = res.headers.get('location');

      if (!locationHeader) {
        throw new Error('Location header is missing');
      }
      sessionUrl = new URL(locationHeader, 'http://localhost:8889').toString();
      return res.text().then((answer) => onRemoteAnswer(answer));
    })
    .catch((err) => {
      onError(err.toString(), true);
    });
};

const editOffer = (sdp: any) => {
  console.log('editOffer', sdp);
  const sections = sdp.split('m=');
  console.log('sections', sections);
  for (let i = 0; i < sections.length; i++) {
    if (sections[i].startsWith('video')) {
      // 设置 SDP 中 vedio 的编码率 
      sections[i] = setCodec(sections[i], videoForm.codec);
    } else if (sections[i].startsWith('audio')) {
      // 设置 SDP 中 audio 的编码率和波特率
      sections[i] = setAudioBitrate(
        setCodec(sections[i], audioForm.codec),
        audioForm.bitrate,
        audioForm.voice
      );
    }
  }
  return sections.join('m=');
};

// // 接受远端 SDP信息的 Answer
const onRemoteAnswer = (sdp: string) => {
  if (restartTimeout !== null) {
    return;
  }
  sdp = editAnswer(sdp);
  // 保存远端 SDP信息的 Answer
  pc.setRemoteDescription(
    new RTCSessionDescription({
      type: 'answer',
      sdp,
    })
  )
    .then(() => {
      if (queuedCandidates.length !== 0) {
        sendLocalCandidates(queuedCandidates);
        queuedCandidates = [];
      }
    })
    .catch((err: any) => {
      onError(err.toString());
    });
};


const editAnswer = (sdp: any) => {
  const sections = sdp.split('m=');

  for (let i = 0; i < sections.length; i++) {
    if (sections[i].startsWith('video')) {
      sections[i] = setVideoBitrate(sections[i], videoForm.bitrate);
    }
  }

  return sections.join('m=');
};

设置 vedio 和 audio 编码格式

// 设置 video 波特率
const setVideoBitrate = (section: any, bitrate: any) => {
  let lines = section.split('
');

  for (let i = 0; i < lines.length; i++) {
    if (lines[i].startsWith('c=')) {
      lines = [
        ...lines.slice(0, i + 1),
        'b=TIAS:' + (parseInt(bitrate) * 1024).toString(),
        ...lines.slice(i + 1),
      ];
      break;
    }
  }

  return lines.join('
');
};

//设置编码格式
const setCodec = (section: any, codec: any) => {
  const lines = section.split('
');
  const lines2 = [];
  const payloadFormats = [];

  for (const line of lines) {
    if (!line.startsWith('a=rtpmap:')) {
      lines2.push(line);
    } else {
      if (line.toLowerCase().includes(codec)) {
        payloadFormats.push(line.slice('a=rtpmap:'.length).split(' ')[0]);
        lines2.push(line);
      }
    }
  }

  const lines3 = [];
  let firstLine = true;

  for (const line of lines2) {
    if (firstLine) {
      firstLine = false;
      lines3.push(line.split(' ').slice(0, 3).concat(payloadFormats).join(' '));
    } else if (line.startsWith('a=fmtp:')) {
      if (payloadFormats.includes(line.slice('a=fmtp:'.length).split(' ')[0])) {
        lines3.push(line);
      }
    } else if (line.startsWith('a=rtcp-fb:')) {
      if (
        payloadFormats.includes(line.slice('a=rtcp-fb:'.length).split(' ')[0])
      ) {
        lines3.push(line);
      }
    } else {
      lines3.push(line);
    }
  }

  return lines3.join('
');
};

const setAudioBitrate = (section: string, bitrate: string, voice: any) => {
  let opusPayloadFormat = '';
  let lines = section.split('
');

  for (let i = 0; i < lines.length; i++) {
    if (
      lines[i].startsWith('a=rtpmap:') &&
      lines[i].toLowerCase().includes('opus/')
    ) {
      opusPayloadFormat = lines[i].slice('a=rtpmap:'.length).split(' ')[0];
      break;
    }
  }

  if (opusPayloadFormat === '') {
    return section;
  }

  for (let i = 0; i < lines.length; i++) {
    if (lines[i].startsWith('a=fmtp:' + opusPayloadFormat + ' ')) {
      if (voice) {
        lines[i] =
          'a=fmtp:' +
          opusPayloadFormat +
          ' minptime=10;useinbandfec=1;maxaveragebitrate=' +
          (parseInt(bitrate) * 1024).toString();
      } else {
        lines[i] =
          'a=fmtp:' +
          opusPayloadFormat +
          ' maxplaybackrate=48000;stereo=1;sprop-stereo=1;maxaveragebitrate=' +
          (parseInt(bitrate) * 1024).toString();
      }
    }
  }

  return lines.join('
');
};

错误处理函数:

const onError = (err: string, retry?: boolean) => {
  if (!retry) {
    console.error('err:', err);
  } else {
    if (restartTimeout === null) {
      console.error(err + ', retrying in some seconds');

      if (pc !== null) {
        pc.close();
        pc = null;
      }

      restartTimeout = window.setTimeout(() => {
        restartTimeout = null;
        startTransmit();
      }, retryPause);

      if (sessionUrl) {
        fetch(sessionUrl, {
          method: 'DELETE',
        });
      }
      sessionUrl = '';
      // 清空 STUN 服务器候选队列
      queuedCandidates = [];
    }
  }
};

注意

关于 vedio 设置
const videoForm = {
  device: '', // 设备ID:none,screen(屏幕),空值默认为外部设备,若没有则为OBS虚拟设备
  codec: 'h264/90000', // 编解码器格式有
  bitrate: '10000', // 比特率
  framerate: '30', // 帧率
  width: '1920',
  height: '1080',
};

例如其中 codec 的设置为h264/90000,其中90000是时钟频率,用于时间戳的单位,它表示每秒钟可以产生90000个时间单位,用于确保视频流和音频流的同步。若设置为 h264 ,则会导致发送的 SDP 中缺少编码协议,导致 WebRTC 建立失败。

搜集到的网络信息candidates

host候选:

candidate:1799829579 1 udp 2122260223 10.102.24.113 51222 typ host generation 0 ufrag 1Phf network-id 1

10.102.24.113 是我电脑内WSL虚拟网络适配器的IP

a=candidate:66318701 1 udp 2122194687 192.168.64.1 51223 typ host generation 0 ufrag 1Phf network-id 2

192.168.64.1 电脑以太网适配器的地址

这些是主机候选,表示的是客户端本地网络中的IP地址(如10.102.24.113192.168.64.1)。这些地址通常是私有IP地址,无法被公网直接访问。

添加STUN/TRUN

srflx候选:

a=candidate:2861133569 1 udp 1686052607 221.xx.xx.xxx 51222 typ srflx raddr 10.102.24.113 rport 51222 generation 0 ufrag 1Phf network-id 1

这个候选是通过STUN服务器获取的反射候选(srflx),显示外部的可路由地址(即公网IP),在这个例子中为221.xx.xx.xxx。这意味着 STUN 服务器成功返回了一个公网 IP 地址。

结果

当 mediamtx 反馈下面 info,即代表 WebRTC 连接和传输媒体流成功

这样媒体流就可以保存在 mediamtx 服务器上了。服务器上查询、转发媒体流等方法均可以在手册中获取。

GitHub - bluenviron/mediamtx: Ready-to-use SRT / WebRTC / RTSP / RTMP / LL-HLS media server and media proxy that allows to read, publish, proxy, record and playback video and audio streams.

菜鸟第一次写文章,对自己项目中用到的模块,通过查阅和学习完成自己的见解,如果可以帮助到你,请帮忙点点赞。可能有用词不当和错误的地方,请大家斧正,感谢阅读!!!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/934149.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

小程序IOS安全区域优化:safe-area-inset-bottom

ios下边有一个小黑线&#xff0c;位于底部的元素会被黑线阻挡 safe-area-inset-bottom 一 用法及作用&#xff1a; IOS全面屏底部有小黑线&#xff0c;位于底部的元素会被黑线阻挡&#xff0c;可以使用以下样式&#xff1a; .model{padding-bottom: constant(safe-area-ins…

NVR小程序接入平台EasyNVR国标协议接入无告警是什么原因?

在现代视频监控系统中&#xff0c;国标接入已成为一种重要的技术标准&#xff0c;尤其是在GB28181协议的推动下&#xff0c;这一标准被广泛应用于安防设备的统一接入和管理。国标接入不仅提高了设备间的互联互通能力&#xff0c;还为用户提供了更高效、更智能的视频监控解决方案…

在CSDN设置“关注博主即可阅读全文”

我们在平时CSDN上搜索文章&#xff0c;打开文章&#xff0c;需要关注博主方可继续阅读的&#xff0c;相必有人会很困惑&#xff0c;也有人会觉得很烦。一般选择先关注&#xff0c;看完取消关注&#xff0c;不管怎么说&#xff0c;今天我来教大家如何设置“关注博主即可阅读全文…

《AI行政管理:开启高效治理新时代》

一、引言 AI 行政管理能力的定义和重要性 AI 行政管理能力是指人工智能在行政管理领域的应用能力。它涵盖了多个方面&#xff0c;包括政府决策支持、公共服务优化、行政流程自动化、社会治理与公共安全以及政府内部管理等。在当今时代&#xff0c;AI 行政管理能力具有至关重要…

`yarn list --pattern element-ui` 是一个 Yarn 命令,用于列出项目中符合指定模式(`element-ui`)的依赖包信息

文章目录 命令解析&#xff1a;功能说明&#xff1a;示例输出&#xff1a;使用场景&#xff1a; yarn list --pattern element-ui 是一个 Yarn 命令&#xff0c;用于列出项目中符合指定模式&#xff08; element-ui&#xff09;的依赖包信息。 命令解析&#xff1a; yarn list…

Vue前端实现预览并打印PDF文档

一. 需求 1. 点击文档列表中的【打印】按钮&#xff0c;获取后台生成的PDF的url&#xff0c;弹窗进行预览&#xff1a; 2. 点击【打印】按钮&#xff0c;进行打印预览和打印&#xff1a; 二. 需求实现 首先后台给的是word文档&#xff0c;研究了一圈后发现暂时无法实现&…

【开源】A066—基于JavaWeb的农产品直卖平台的设计与实现

&#x1f64a;作者简介&#xff1a;在校研究生&#xff0c;拥有计算机专业的研究生开发团队&#xff0c;分享技术代码帮助学生学习&#xff0c;独立完成自己的网站项目。 代码可以查看项目链接获取⬇️&#xff0c;记得注明来意哦~&#x1f339; 赠送计算机毕业设计600个选题ex…

MR20一体式IO 在3C领域的应用

一、导读 该公司成立于1999年&#xff0c;是中国最早专注于泛半导体产业的投资机构&#xff0c;于2015年在 新三板上市。是集研发&#xff0c;制造&#xff0c;销售&#xff0c;服务于一体的国家级高新技术企业&#xff0c;致力于提供暖通空调及供热采暖 控制为核心的产品、技…

Conda + JuiceFS :增强 AI 开发环境共享能力

Conda 是当前 AI 应用开发领域中非常流行的环境和包管理系统&#xff0c;因其能够简单便捷地创建与系统资源相隔离的虚拟环境广受欢迎。 Conda 支持在不同的操作系统上重建相同的工作环境&#xff0c;但在环境共享复用方面仍存在一些挑战。比如&#xff0c;在不同机器上复用相…

【推荐算法】单目标精排模型——FiBiNET

key word: 学术论文 Motivation&#xff1a; 传统的Embedding&MLP算法是通过内积和Hadamard product实现特征交互的&#xff0c;这篇文章的作者提出了采用SENET实现动态学习特征的重要性&#xff1b;作者认为简单的内积和Hadamard product无法有效对稀疏特征进行特征交互&a…

启动你的RocketMQ之旅(二)-broket和namesrv启动流程

前言&#xff1a; &#x1f44f;作者简介&#xff1a;我是笑霸final&#xff0c;一名热爱技术的在校学生。 &#x1f4dd;个人主页&#xff1a; 笑霸final的主页2 &#x1f4d5;系列专栏&#xff1a;java专栏 &#x1f4e7;如果文章知识点有错误的地方&#xff0c;请指正&#…

vue3-canvas实现在图片上框选标记(放大,缩小,移动,删除)

双图版本&#xff08;模板对比&#xff09; 业务描述&#xff1a;模板与图片对比&#xff0c;只操作模板框选的位置进行色差对比&#xff0c;传框选坐标位置给后端&#xff0c;返回对比结果显示 draw.js文件&#xff1a; 新增了 createUuid&#xff0c;和求取两个数组差集的方…

python编程Day13-异常介绍捕获异常抛出异常

异常 介绍 1, 程序在运行时, 如果Python解释器遇到到一个错误, 则会停 止程序的执行, 并且提示一些错误信息, 这就是异常. 2, 程序停止执行并且提示错误信息这个动作, 通常称之为: 抛出 (raise) 异常 # f open(aaaa.txt) # FileNotFoundError: [Errno 2] No such file or dire…

计网(王道的总结)-数据链路层-网络层-传输层

由于时间有限&#xff0c;把每个王道的章节最后一节放在一起&#xff0c;分别看看复习知识点。 3.6.4 IEEE 802.11 无线局域网 重点&#xff1a; 3.7 广域网 真题考频&#xff1a;极低 3.8以太网交换机 4.1网络层的功能 4.2.1IPv4分组 最重要的&#xff1a; TTL&#xff1a;…

【优选算法篇】:揭开二分查找算法的神秘面纱--数据海洋中的精准定位器

✨感谢您阅读本篇文章&#xff0c;文章内容是个人学习笔记的整理&#xff0c;如果哪里有误的话还请您指正噢✨ ✨ 个人主页&#xff1a;余辉zmh–CSDN博客 ✨ 文章所属专栏&#xff1a;c篇–CSDN博客 文章目录 一.二分查找算法二.算法模板模板一模板二模板三 三.例题演练1.x的平…

PlantUML——类图

背景 类图是UML模型中的静态视图&#xff0c;其主要作用包括&#xff1a; 描述系统的结构化设计&#xff0c;显示出类、接口以及它们之间的静态结构和关系。简化对系统的理解&#xff0c;是系统分析与设计阶段的重要产物&#xff0c;也是系统编码和测试的重要模型依据。 在U…

来也RPA程序异常处理

1、程序异常模块怎么弄&#xff1a;连接第一个流程块后&#xff0c;连接第二个流程块就是虚线异常块。这是编辑器固定的功能。 2、异常模块做什么&#xff1f;系统会自动把异常文本&#xff0c;通输入参数 $BlockInput 传入异常流程块。 然后&#xff0c;这个异常文本&#xf…

电子应用设计方案-43:智能手机充电器系统方案设计

智能手机充电器系统方案设计 一、引言 随着智能手机的广泛应用&#xff0c;对充电器的性能、效率和安全性提出了更高的要求。本方案旨在设计一款高效、安全、兼容多种快充协议的智能手机充电器。 二、系统概述 1. 系统目标 - 提供快速、稳定、安全的充电功能。 - 兼容主流的智…

Java Agent(一)、 初步认识Instrumentation

目录 1、什么是Instrumentation&#xff1f; 2、底层机制 2.1、工作流程 2.2、Instrumentation API 3、加载Java Agent 3.1、静态Agent示例 3.1.1、定义一个agent 3.1.2、配置 MANIFEST.MF 3.1.3、定义main测试类 3.1.4、启动参数添加-javaagent 3.2、动态Agent示例…

关于SpringBoot项目创建后构建总是失败的问题

第一个问题&#xff1a;IDEA创建项目总是失败 原因&#xff1a;创建项目的时候默认使用的是https://start.spring.io&#xff0c;这个是一个外国网站&#xff0c;众所周知的就是国内访问总是出现不稳定的现象&#xff0c;这就是导致项目创建失败的最终原因。 解决方法&#x…