我们将选择业界最流行、性能最高的开源SFU之一——Mediasoup来构建。选择它的原因:

  • 高性能: 采用C++编写核心媒体处理逻辑。

  • 灵活性: 通过Node.js模块进行控制,可以和我们现有的信令服务器无缝集成。

  • 现代设计: API设计优秀,概念清晰。

项目架构与总览

我们的新系统将由三部分组成,运行在同一台服务器上(为了简化):

  1. 服务器应用 (Server App):

    • 这是一个Node.js应用。

    • 它内部包含了两个角色

      • 信令服务器 (Signaling): 负责处理WebSocket连接,转发信令。我们将改造现有的server.js。

      • 媒体控制器 (Media Controller): 负责创建和管理Mediasoup的媒体资源(Worker, Router, Transport等)。

  2. Mediasoup C++进程 (Worker):

    • 这是媒体处理的核心,负责接收、转发音视频流。

    • 我们的Node.js应用会启动并控制这些C++进程。我们不需要直接编写C++代码。

  3. 客户端 (Client):

    • APP端(WEB端)和WEB端。

    • 需要改造,使用mediasoup-client库来与SFU进行交互。

第一步:环境准备与服务器改造

目标: 搭建一个集成了Mediasoup的Node.js服务器。

1. 环境要求

Mediasoup的C++部分需要编译,对环境有一定要求:

  • Python 3.x

  • GNU Make 和一个 C++17 编译器 (如 GCC 8.x+ 或 Clang 7+)

    • 在Windows上: 最简单的方式是安装 Visual Studio 2019 (or newer) Build Tools,并确保勾选了 "Desktop development with C++" 工作负载。

    • 在Linux (Ubuntu/Debian)上: sudo apt-get install -y python3 make g++

    • 在macOS上: 安装 Xcode Command Line Tools 即可。

2. 改造现有服务器项目

我们回到之前的 signaling-server 目录。

  1. 安装Mediasoup:
    在signaling-server目录下,运行命令安装Mediasoup。这个过程会自动编译C++部分,所以会比较慢,请耐心等待。

    codeBash
    npm install mediasoup@3
  2. 更新package.json:
    你的dependencies现在应该包含了mediasoup。

  3. 创建新的服务器主文件 app.js:
    我们将创建一个新的主文件app.js,它将包含信令逻辑和Mediasoup控制逻辑。原来的server.js可以先留着备用或删除。

    在 signaling-server 目录下创建 app.js,并粘贴以下骨架代码。这是我们服务器的全新起点。

    codeJavaScript
    // app.js
    const express = require('express');
    const http = require('http');
    const { Server } = require("socket.io");
    const mediasoup = require('mediasoup');
    const config = require('./config'); // 我们将把配置分离出去
    
    // --- 全局变量 ---
    let worker; // Mediasoup C++ Worker
    let router; // Mediasoup Router
    const rooms = {}; // 用于存储所有房间信息
    
    // --- 初始化 Express 和 Socket.IO ---
    const app = express();
    const httpServer = http.createServer(app);
    const io = new Server(httpServer, {
        cors: { origin: "*" }
    });
    
    // --- 启动服务器 ---
    httpServer.listen(config.listenPort, () => {
        console.log(`服务器已启动,正在监听端口: ${config.listenPort}`);
    });
    
    // --- Mediasoup Worker 和 Router 的启动函数 ---
    async function startMediasoup() {
        worker = await mediasoup.createWorker({
            logLevel: config.mediasoup.worker.logLevel,
            logTags: config.mediasoup.worker.logTags,
            rtcMinPort: config.mediasoup.worker.rtcMinPort,
            rtcMaxPort: config.mediasoup.worker.rtcMaxPort,
        });
    
        worker.on('died', () => {
            console.error('mediasoup worker died, exiting in 2 seconds...');
            setTimeout(() => process.exit(1), 2000);
        });
    
        const mediaCodecs = config.mediasoup.router.mediaCodecs;
        router = await worker.createRouter({ mediaCodecs });
        console.log('Mediasoup Worker 和 Router 已成功创建!');
    }
    
    // --- 启动 Mediasoup ---
    startMediasoup();
    
    // --- Socket.IO 信令处理逻辑 (后续填充) ---
    io.on('connection', (socket) => {
        console.log(`新客户端连接: ${socket.id}`);
    
        socket.on('disconnect', () => {
            console.log(`客户端断开连接: ${socket.id}`);
            // TODO: 清理该客户端相关的资源
        });
    
        // 在这里处理所有来自客户端的信令请求
        // 例如: joinRoom, getRouterRtpCapabilities, createTransport, connectTransport, produce, subscribe 等
    });
  4. 创建配置文件 config.js:
    将配置与代码分离是个好习惯。在 signaling-server 目录下创建 config.js:

    codeJavaScript
    // config.js
    module.exports = {
        listenPort: 3000,
        mediasoup: {
            worker: {
                rtcMinPort: 10000,
                rtcMaxPort: 10100,
                logLevel: 'warn',
                logTags: [
                    'info',
                    'ice',
                    'dtls',
                    'rtp',
                    'srtp',
                    'rtcp',
                ],
            },
            router: {
                mediaCodecs: [
                    {
                        kind: 'video',
                        mimeType: 'video/VP8',
                        clockRate: 90000,
                        parameters: {
                            'x-google-start-bitrate': 1000,
                        },
                    },
                    // 如果需要音频,可以添加音频编解码器
                    // {
                    //     kind: 'audio',
                    //     mimeType: 'audio/opus',
                    //     clockRate: 48000,
                    //     channels: 2
                    // }
                ],
            },
            // WebRtcTransport options
            webRtcTransport: {
                listenIps: [
                    {
                        ip: '0.0.0.0', // 监听所有IP地址
                        announcedIp: null, // Mediasoup会自动发现公网IP,局域网内设为null即可
                    },
                ],
                enableUdp: true,
                enableTcp: true,
                preferUdp: true,
            },
        },
    };

    注意: rtcMinPort 和 rtcMaxPort 定义了Mediasoup用于WebRTC传输的UDP/TCP端口范围。请确保你的防火墙允许这个范围的端口通信。

  5. 启动服务器进行初步测试:
    现在,运行命令:

    codeBash
    node app.js

    如果你的环境配置正确,你应该能看到:

    codeCode
    服务器已启动,正在监听端口: 3000
    Mediasoup Worker 和 Router 已成功创建!

    这表示Mediasoup的核心已经成功跑起来了!现在,我们需要填充信令逻辑。


第二步:实现服务器端的信令逻辑

目标: 编写处理客户端加入房间、创建传输通道、生产和消费流的逻辑。

这个app.js文件包含了完整的信令处理逻辑Mediasoup媒体控制逻辑。请将这个文件的全部内容,替换掉你当前 signaling-server/app.js 文件中的所有内容。

完整的 app.js (信令服务器 + Mediasoup控制器)

codeJavaScript

// app.js

const express = require('express');
const http = require('http');
const { Server } = require("socket.io");
const mediasoup = require('mediasoup');
const config = require('./config'); // 确保你的config.js文件在同级目录

const app = express();
const httpServer = http.createServer(app);
const io = new Server(httpServer, {
    cors: { origin: "*" }
});

let worker;
let router;
let producerTransport;
let producer;
// 简单的房间模型,这里我们只创建一个全局的房间
const peers = {}; // key: socket.id, value: { transports, producers, consumers }

/**
 * 启动 Mediasoup Worker
 */
async function startMediasoupWorker() {
    worker = await mediasoup.createWorker({
        logLevel: config.mediasoup.worker.logLevel,
        logTags: config.mediasoup.worker.logTags,
        rtcMinPort: config.mediasoup.worker.rtcMinPort,
        rtcMaxPort: config.mediasoup.worker.rtcMaxPort,
    });

    worker.on('died', () => {
        console.error('mediasoup worker died, exiting in 2 seconds...');
        setTimeout(() => process.exit(1), 2000);
    });

    const mediaCodecs = config.mediasoup.router.mediaCodecs;
    router = await worker.createRouter({ mediaCodecs });
    console.log('Mediasoup Worker 和 Router 已成功创建!');
}

// 启动 Mediasoup
startMediasoupWorker();


io.on('connection', (socket) => {
    console.log(`新客户端连接: ${socket.id}`);
    peers[socket.id] = {
        transports: new Map(),
        producers: new Map(),
        consumers: new Map()
    };

    socket.on('disconnect', () => {
        console.log(`客户端断开连接: ${socket.id}`);
        // 清理该客户端的所有资源
        if (peers[socket.id]) {
            peers[socket.id].transports.forEach(transport => transport.close());
            delete peers[socket.id];
        }
    });

    // 客户端请求Router的RTP能力
    socket.on('getRouterRtpCapabilities', (data, callback) => {
        callback(router.rtpCapabilities);
    });

    // 客户端请求创建Transport
    socket.on('createWebRtcTransport', async (data, callback) => {
        try {
            const { producing, consuming } = data;
            
            const webRtcTransport_options = {
                listenIps: config.mediasoup.webRtcTransport.listenIps,
                enableUdp: true,
                enableTcp: true,
                preferUdp: true,
            };

            let transport = await router.createWebRtcTransport(webRtcTransport_options);
            console.log(`Transport 创建成功: ${transport.id}`);

            peers[socket.id].transports.set(transport.id, transport);

            callback({
                id: transport.id,
                iceParameters: transport.iceParameters,
                iceCandidates: transport.iceCandidates,
                dtlsParameters: transport.dtlsParameters,
            });

        } catch (error) {
            console.error('创建Transport失败:', error);
            callback({ error: error.message });
        }
    });

    // 客户端连接Transport
    socket.on('connectWebRtcTransport', async (data, callback) => {
        const { transportId, dtlsParameters } = data;
        const transport = peers[socket.id].transports.get(transportId);

        if (!transport) {
            console.error(`未找到Transport: ${transportId}`);
            return callback({ error: 'Transport not found' });
        }

        await transport.connect({ dtlsParameters });
        callback({});
    });

    // 客户端开始生产(推流)
    socket.on('produce', async (data, callback) => {
        const { kind, rtpParameters, transportId } = data;
        const transport = peers[socket.id].transports.get(transportId);

        if (!transport) {
            console.error(`未找到Transport: ${transportId}`);
            return callback({ error: 'Transport not found' });
        }

        // 核心:调用 transport.produce()
        producer = await transport.produce({ kind, rtpParameters });
        
        peers[socket.id].producers.set(producer.id, producer);

        console.log(`Producer 已创建: ${producer.id}`);

        // 通知所有其他客户端,有新的producer产生了
        socket.broadcast.emit('newProducer', { producerId: producer.id });

        callback({ id: producer.id });
    });

    // 客户端请求消费(拉流)
    socket.on('consume', async (data, callback) => {
        const { rtpCapabilities, producerId, transportId } = data;

        if (!producer || producer.id !== producerId) {
             console.error(`未找到要消费的Producer: ${producerId}`);
             return callback({ error: 'Producer not found' });
        }
        
        // 检查接收端是否能消费这个流
        if (!router.canConsume({ producerId, rtpCapabilities })) {
            console.error('客户端无法消费此流');
            return callback({ error: 'Cannot consume' });
        }

        const transport = peers[socket.id].transports.get(transportId);
        if (!transport) {
            console.error(`未找到Transport: ${transportId}`);
            return callback({ error: 'Transport not found' });
        }

        try {
            let consumer = await transport.consume({
                producerId,
                rtpCapabilities,
                paused: true, // 创建时先暂停
            });
            
            peers[socket.id].consumers.set(consumer.id, consumer);

            consumer.on('transportclose', () => {
                console.log(`Consumer的Transport关闭: ${consumer.id}`);
            });

            consumer.on('producerclose', () => {
                console.log(`Consumer的Producer关闭: ${consumer.id}`);
            });

            const params = {
                id: consumer.id,
                producerId,
                kind: consumer.kind,
                rtpParameters: consumer.rtpParameters,
            };

            callback(params);

        } catch (error) {
            console.error('创建Consumer失败:', error);
            callback({ error: error.message });
        }
    });

    // 客户端准备好后,请求恢复播放
    socket.on('resume', async (data, callback) => {
        const { consumerId } = data;
        const consumer = peers[socket.id].consumers.get(consumerId);

        if (!consumer) {
            console.error(`未找到Consumer: ${consumerId}`);
            return callback({ error: 'Consumer not found' });
        }

        await consumer.resume();
        callback({});
    });
});


// --- 最后,启动服务器 ---
httpServer.listen(config.listenPort, () => {
    console.log(`服务器已启动,正在监听端口: ${config.listenPort}`);
});

如何使用这份代码

  1. 替换文件:确保将上述完整代码复制并完全覆盖你项目中的 signaling-server/app.js 文件。

  2. 检查 config.js: 确保你的 config.js 文件存在于 app.js 的同级目录下,并且内容正确。

  3. 检查 package.json: 确保你的 dependencies 中已经包含了 express, socket.io, mediasoup。

  4. 启动服务器: 在signaling-server目录下运行 node app.js。

这份代码实现了一个简化的单房间模型

  • 第一个连接上来并推流的客户端,会被认定为唯一的Producer (发送端)

  • 之后所有连接上来的客户端,都会在服务器收到newProducer事件后,自动尝试去消费 (subscribe) 这个已经存在的流。

这非常符合我们“一个发送端,多个接收端”的监控场景。

接下来,你需要改造你的客户端代码来与这个新的信令服务器进行交互。

第三步:改造WEB客户端 - 准备工作

安装并准备 mediasoup-client 库

Mediasoup在浏览器端需要一个专门的库来配合工作

方案:使用打包工具 (推荐,一劳永逸)

这是最专业、最推荐的做法。我们将使用一个极其简单的打包工具 Vite,它以“快”和“零配置”著称。

1. 初始化Vite项目(中途会遇到vite版本问题导致下载和运行报错,降低vite的版本即可一般7.0.0及以下都可以)

  • 在你的项目根目录 D:\webrtc-project 下打开命令行。

  • 运行以下命令,使用Vite来创建一个新的前端项目(我们会把现有的web-client内容移进去):

    codeBash
    npm create vite@latest web-frontend -- --template vanilla
    • web-frontend 是我们将要创建的新文件夹名。

    • --template vanilla 表示创建一个最基础的、纯JavaScript的项目。

  • 根据提示,进入新创建的目录并安装依赖:

    codeBash
    cd web-frontend
    npm install

2. 安装 mediasoup-client 和 socket.io-client

  • 在新创建的 web-frontend 目录里,为这个前端项目单独安装它所需要的库:

    codeBash
    npm install mediasoup-client@3 socket.io-client

3. 迁移你的文件

  • 将你之前 web-client 文件夹下的 sender.html, receiver.html, sender.js, receiver.js 移动或复制到新的 web-frontend 文件夹的根目录下。

  • Vite的入口HTML文件是 index.html,你可以把receiver.html的内容复制到index.html里,或者保留多个HTML文件(Vite也支持多页面)。我们先以receiver.html和sender.html为例。

4. 修改HTML以适应模块化

  • 修改 sender.html 和 receiver.html:

    • 删除 <script src="socket.io.min.js">。注意: 之前引入socket.io.min.js的<script>标签必须删除

    • 修改你自己的JS文件引入方式,在<script>标签中加入 type="module"。

      codeHtml
      <!-- sender.html -->
      <script type="module" src="/sender.js"></script>
      
      <!-- receiver.html -->
      <script type="module" src="/receiver.js"></script>

完整的receiver.html:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>WebRTC 共享接收端</title>
    <style>
        body { font-family: sans-serif; background-color: #f0f0f0; padding: 20px; }
        h1 { text-align: center; }
        #video-container { max-width: 800px; margin: 20px auto; background-color: black; }
        video { width: 100%; display: block; }
        #status { text-align: center; margin-top: 10px; font-size: 1.2em; }
    </style>
</head>
<body>
    <h1>WebRTC 网页共享 - 接收端</h1>
    <div id="video-container">
        <video id="remoteVideo" autoplay playsinline muted></video> <!-- 加上muted确保自动播放 -->
    </div>
    <p id="status">正在等待共享方发起连接...</p>
    
    <script type="module" src="receiver.js"></script>
</body>
</html>

完整的sender.html:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>WebRTC 共享发送端</title>
    <style>
        body { font-family: sans-serif; text-align: center; padding-top: 50px; }
        button { font-size: 1.5em; padding: 10px 20px; cursor: pointer; }
        #localVideo { display: none; } /* 本地预览视频默认不显示 */
    </style>
</head>
<body>
    <h1>WebRTC 网页共享 - 发送端</h1>
    <p>点击下方按钮,选择要共享的浏览器标签页。</p>
    <button id="startButton">开始共享</button>
    <p id="status">准备就绪</p>

    <!-- 用于本地预览(可选) -->
    <!-- <video id="localVideo" autoplay muted playsinline></video> -->

    <script type="module" src="sender.js"></script>
</body>
</html>

5.sender.js 完整模块化代码

请用以下完整代码替换你的 web-frontend/sender.js 文件。

主要的改动在文件的最顶部和创建Device实例的地方。

codeJavaScript

// sender.js (Vite 模块化版本)

import { Device } from "mediasoup-client";
import { io } from "socket.io-client";

// --- 1. 配置 ---
const SERVER_IP = '192.168.0.102'; // !!! 务必修改为你的服务器IP地址
const SERVER_PORT = 3000;

const startButton = document.getElementById('startButton');
const statusDiv = document.getElementById('status');

let device;
let sendTransport;
let producer;
let localStream;

const socket = io(`http://${SERVER_IP}:${SERVER_PORT}`);

// --- 2. 信令服务器连接 ---
socket.on('connect', () => {
    console.log('成功连接到信令服务器!');
    statusDiv.textContent = '已连接信令服务器';
    startButton.disabled = false;
});

socket.on('disconnect', () => {
    console.log('与信令服务器断开连接');
    statusDiv.textContent = '信令连接已断开';
    startButton.disabled = true;
});

// --- 3. 核心功能函数 ---

/**
 * 封装一个Promise风格的socket.emit请求
 */
function sendRequest(type, payload) {
    return new Promise((resolve, reject) => {
        socket.emit(type, payload, (data) => {
            if (data && data.error) {
                console.error(`请求'${type}'失败:`, data.error);
                reject(data.error);
            } else {
                resolve(data);
            }
        });
    });
}

/**
 * 开始屏幕共享的主流程
 */
async function startSharing() {
    try {
        startButton.disabled = true;
        statusDiv.textContent = '正在请求屏幕捕获...';

        // 1. 获取本地媒体流
        localStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
        const videoTrack = localStream.getVideoTracks()[0];
        
        statusDiv.textContent = '屏幕捕获成功,正在初始化Mediasoup...';

        // 2. 获取Router RTP Capabilities
        const routerRtpCapabilities = await sendRequest('getRouterRtpCapabilities', {});
        console.log('获取Router RTP Capabilities成功:', routerRtpCapabilities);

        // 3. 创建Mediasoup Device
        device = new Device(); // <-- 修改点:不再需要 mediasoupClient.Device
        await device.load({ routerRtpCapabilities });
        console.log('Mediasoup Device 加载成功');

        // 4. 创建发送Transport
        const transportInfo = await sendRequest('createWebRtcTransport', {});
        console.log('创建Send Transport成功:', transportInfo);
        sendTransport = device.createSendTransport(transportInfo);

        // 5. 监听Transport的 'connect' 和 'produce' 事件
        sendTransport.on('connect', async ({ dtlsParameters }, callback, errback) => {
            console.log('Transport "connect" event');
            try {
                // 将DTLS参数发送给服务器
                await sendRequest('connectWebRtcTransport', { transportId: sendTransport.id, dtlsParameters });
                callback(); // 告诉Transport连接成功
            } catch (error) {
                errback(error);
            }
        });

        sendTransport.on('produce', async ({ kind, rtpParameters }, callback, errback) => {
            console.log('Transport "produce" event');
            try {
                // 将媒体信息发送给服务器,获取producer id
                const { id } = await sendRequest('produce', {
                    transportId: sendTransport.id,
                    kind,
                    rtpParameters,
                });
                callback({ id }); // 告诉Transport,服务器已成功创建Producer
            } catch (error) {
                errback(error);
            }
        });

        // 6. 调用transport.produce()开始推流
        producer = await sendTransport.produce({ track: videoTrack });

        statusDiv.textContent = '共享已开始!';
        console.log('成功创建Producer:', producer);

    } catch (error) {
        console.error('开始共享失败:', error);
        statusDiv.textContent = `错误: ${error.message}`;
        startButton.disabled = false;
    }
}

// --- 4. 绑定事件 ---
startButton.disabled = true; // 初始时禁用,等待socket连接成功
startButton.onclick = startSharing;
6.receiver.js 完整模块化代码

请用以下完整代码替换你的 web-frontend/receiver.js 文件。

codeJavaScript

// receiver.js (Vite 模块化版本)

import { Device } from "mediasoup-client";
import { io } from "socket.io-client";

// --- 1. 配置 ---
const SERVER_IP = '192.168.0.102'; // !!! 务必修改为你的服务器IP地址
const SERVER_PORT = 3000;

const remoteVideo = document.getElementById('remoteVideo');
const statusDiv = document.getElementById('status');

let device;
let recvTransport;

const socket = io(`http://${SERVER_IP}:${SERVER_PORT}`);

// --- 2. 信令服务器连接 ---
socket.on('connect', async () => {
    console.log('成功连接到信令服务器!');
    statusDiv.textContent = '已连接信令服务器,正在初始化...';
    try {
        await initialize();
    } catch (error) {
        console.error("初始化失败:", error);
        statusDiv.textContent = `错误: ${error.message}`;
    }
});

socket.on('disconnect', () => {
    console.log('与信令服务器断开连接');
    statusDiv.textContent = '信令连接已断开';
});

// 服务器通知有新的生产者
socket.on('newProducer', ({ producerId }) => {
    console.log(`收到新Producer通知: ${producerId}`);
    // 开始消费这个新的Producer
    consume(producerId);
});

// --- 3. 核心功能函数 ---

/**
 * 封装一个Promise风格的socket.emit请求
 */
function sendRequest(type, payload) {
    return new Promise((resolve, reject) => {
        socket.emit(type, payload, (data) => {
            if (data && data.error) {
                console.error(`请求'${type}'失败:`, data.error);
                reject(data.error);
            } else {
                resolve(data);
            }
        });
    });
}

/**
 * 初始化接收端
 */
async function initialize() {
    statusDiv.textContent = '正在获取Router能力...';
    // 1. 获取Router RTP Capabilities
    const routerRtpCapabilities = await sendRequest('getRouterRtpCapabilities', {});
    console.log('获取Router RTP Capabilities成功:', routerRtpCapabilities);

    // 2. 创建Mediasoup Device
    device = new Device(); // <-- 修改点:不再需要 mediasoupClient.Device
    await device.load({ routerRtpCapabilities });
    console.log('Mediasoup Device 加载成功');

    statusDiv.textContent = '正在创建接收通道...';
    // 3. 创建接收Transport
    const transportInfo = await sendRequest('createWebRtcTransport', {});
    console.log('创建Recv Transport成功:', transportInfo);
    recvTransport = device.createRecvTransport(transportInfo);

    // 4. 监听Transport的 'connect' 事件
    recvTransport.on('connect', ({ dtlsParameters }, callback, errback) => {
        console.log('Transport "connect" event');
        sendRequest('connectWebRtcTransport', {
            transportId: recvTransport.id,
            dtlsParameters,
        })
        .then(callback)
        .catch(errback);
    });

    statusDiv.textContent = '准备就绪,等待发送端开始共享...';
}

/**
 * 消费(订阅)一个指定的Producer
 */
async function consume(producerId) {
    if (!device || !recvTransport) {
        console.error('Device或RecvTransport尚未初始化');
        return;
    }
    
    statusDiv.textContent = `正在订阅来自 ${producerId} 的流...`;
    
    const { rtpCapabilities } = device;
    // 请求服务器为我们创建Consumer
    const consumerParams = await sendRequest('consume', { rtpCapabilities, producerId, transportId: recvTransport.id });

    if (!consumerParams) {
        console.error('创建Consumer失败,服务器返回空参数');
        return;
    }

    // 在本地创建Consumer实例
    const consumer = await recvTransport.consume(consumerParams);

    console.log('成功创建Consumer:', consumer);
    
    const { track } = consumer;
    remoteVideo.srcObject = new MediaStream([track]);

    // 重要的是,要告诉服务器可以开始发送数据了
    await sendRequest('resume', { consumerId: consumer.id });

    statusDiv.textContent = '正在播放!';
7.启动和测试
  1. 确保你的signaling-server中的app.js正在运行 (node app.js)。

  2. 进入web-frontend目录。

  3. 运行npm run dev来启动Vite开发服务器。

  4. 在浏览器中打开命令行提示的地址(如 http://localhost:5173/),分别访问sender.html和receiver.html页面进行测试。

8.启动后可能会遇到的一些问题

如何让局域网可访问 (现在不需要,但以后会用到)

  1. 在Vite的命令行窗口按 Ctrl + C 停止当前服务器。

  2. 修改web-frontend/package.json文件,在"dev"脚本里加上--host。

    codeJson
    // package.json
    "scripts": {
      "dev": "vite --host",
      "build": "vite build",
      "preview": "vite preview"
    },
  3. 重新运行 npm run dev。

  4. 这次,Vite的输出会变成类似这样:

    codeCode
    VITE v5.4.19  ready in 189 ms
    
      ➜  Local:   http://localhost:5173/
      ➜  Network: http://192.168.0.102:5173/  <-- 多了这一行!
      ➜  press h + enter to show help

效果图,发现黑屏:

解决方案:

检查 consumer.track 状态并手动播放 (最可能的解决方案)

即使有autoplay,在复杂的异步流程后,有时还是需要手动触发一下play()。我们来添加一些日志,并尝试手动播放。

修改 receiver.js 的 consume 函数:

codeJavaScript

async function consume(producerId) {
    // ... (前面的代码不变) ...

    const consumer = await recvTransport.consume(consumerParams);
    console.log('成功创建Consumer:', consumer);
    
    // --- 新增调试代码 ---
    const { track } = consumer;
    console.log('获取到的Track:', track);
    console.log(`Track状态 (readyState): ${track.readyState}`);
    console.log(`Track是否静音 (muted): ${track.muted}`);
    console.log(`Track是否启用 (enabled): ${track.enabled}`);
    
    // --- 修改渲染逻辑 ---
    const stream = new MediaStream([track]);
    remoteVideo.srcObject = stream;
    
    // 告诉服务器可以开始发送数据了
    await sendRequest('resume', { consumerId: consumer.id });
    
    statusDiv.textContent = '正在尝试播放...';

    // **关键改动:添加一个微小的延时后,手动调用play()**
    // 这是为了确保 srcObject 已经完全被<video>元素加载
    setTimeout(() => {
        remoteVideo.play().then(() => {
            console.log("视频手动播放成功!");
            statusDiv.textContent = '正在播放!';
        }).catch(error => {
            console.error("视频手动播放失败:", error);
            statusDiv.textContent = `播放失败: ${error.message},请尝试点击视频。`;
            // 在播放失败时,可以添加一个点击事件来再次尝试
            remoteVideo.onclick = () => {
                remoteVideo.play();
            };
        });
    }, 100); // 100毫秒的延时通常足够
}
检查浏览器的 WebRTC 内部状态

如果方案一仍然无效,我们可以使用浏览器内置的强大工具来查看底层发生了什么。

  1. 接收端的Chrome浏览器中,打开一个新的标签页。

  2. 在地址栏输入 chrome://webrtc-internals 并回车。

  3. 你会看到一个包含了当前所有WebRTC连接的页面。找到与你的接收端页面对应的条目。

  4. 检查以下几个关键指标:

    • iceConnectionState: 它应该是connected或completed。如果是其他状态,说明P2P连接有问题。

    • bytesReceived: 找到ssrc_..._recv或类似的入站流统计。看看bytesReceived这个值是否在持续增长。如果它在增长,说明视频数据确实在源源不断地到达浏览器,问题100%出在渲染环节。如果它一直是0或不变,说明数据流没有过来,问题可能在SFU或网络层面。

    • framesDecoded: 如果bytesReceived在增长,再看看framesDecoded(解码帧数)是否也在增长。如果也在增长,说明浏览器不仅收到了数据,还成功解码了,那黑屏就非常奇怪了,可能与显卡驱动或浏览器bug有关。

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

我们需要修改服务器端的配置,明确告诉Mediasoup在局域网环境下应该使用哪个IP地址。

修改 signaling-server/config.js 文件:

  1. 找到webRtcTransport的配置部分。

  2. 将announcedIp的值从null修改为你的服务器的局域网IP地址(就是你在客户端JS里填写的那个SERVER_IP)。

codeJavaScript

// config.js

module.exports = {
    // ...
    mediasoup: {
        // ...
        webRtcTransport: {
            listenIps: [
                {
                    ip: '0.0.0.0', // 继续监听所有网络接口
                    // --- 关键修改在这里 ---
                    // 明确宣告服务器的局域网IP地址
                    announcedIp: '192.168.0.102', // <-- 请确保这个IP是你运行app.js的电脑的IP
                },
            ],
            enableUdp: true,
            enableTcp: true,
            preferUdp: true,
        },
    },
};

这个修改的含义是:

  • ip: '0.0.0.0': Mediasoup服务本身依然在服务器的所有网络接口上监听连接。

  • announcedIp: '192.168.0.102': 当客户端来请求连接时,Mediasoup会在生成的ICE候选者中明确告诉客户端:“请通过192.168.0.102这个地址来连接我”,而不是让它自己去猜。

第四步:测试程序

如何测试“一对多”监控 (一个发送端,多个接收端)

这是最符合你监控需求的场景。操作步骤如下:

  1. 确保服务器正在运行:

    • 你的signaling-server中的node app.js正在运行。

  2. 启动Vite开发服务器:

    • 在web-frontend目录下,确保npm run dev正在运行。

  3. 打开多个接收端 (监控屏幕):

    • 在你的浏览器中,打开第一个receiver.html页面。地址是: http://localhost:5173/receiver.html。

    • 再打开一个新标签页,输入相同的地址,打开第二个receiver.html页面。

    • 再打开一个新标签页,打开第三个receiver.html页面。

    • ……你可以根据需要打开任意多个。现在,你就模拟了多个监控屏幕,它们都在静静地等待信号。

  4. 打开发送端 (被监控目标):

    • 在浏览器中,打开一个sender.html页面。地址是:http://localhost:5173/sender.html。

  5. 开始共享:

    • 在sender.html页面,点击“开始共享”按钮,并选择一个你想要共享的标签页或窗口。

  6. 见证奇迹:

    • 切换回你之前打开的所有receiver.html标签页。

    • 你会发现,每一个接收端页面上,都同时开始播放来自发送端的实时画面了!

这个测试完美地证明了SFU的威力:你的发送端只推了一路流,但SFU服务器智能地将它复制并分发给了所有订阅者。

如何测试“多对多” (多个发送端,多个接收端)

我们当前的代码实现是一个简化的“单生产者”模型,所以无法直接测试多对多。但是,只需要对服务器端的app.js做一个非常小的改动,就可以让它支持多个生产者。

第一步:修改 app.js 以支持多生产者

我们需要修改服务器逻辑,让它能够管理一个生产者列表,而不是只管理一个全局的producer。

  1. 修改全局变量:
    将let producer;改为一个对象来存储所有的生产者。

    codeJavaScript
    // app.js
    // let producerTransport; // 可以移除或保留,取决于你的清理逻辑
    // let producer; // <-- 删除这一行
    const producers = new Map(); // <-- 新增:使用Map来存储所有producer,key是producer.id
  2. 修改 produce 事件处理器:
    当新的生产者创建时,将它存入producers这个Map中。

    codeJavaScript
    // 'produce' 事件
    socket.on('produce', async (data, callback) => {
        // ...
        const transport = peers[socket.id].transports.get(transportId);
        // ...
        
        const producer = await transport.produce({ kind, rtpParameters }); // <-- producer变为局部变量
        
        peers[socket.id].producers.set(producer.id, producer);
        producers.set(producer.id, producer); // <-- 新增:将producer存入全局列表
    
        console.log(`Producer 已创建: ${producer.id}`);
    
        // 当producer关闭时,从列表中移除
        producer.on('transportclose', () => {
            console.log(`Producer's transport closed: ${producer.id}`);
            producers.delete(producer.id);
        });
    
        // 通知所有其他客户端,有新的producer产生了
        socket.broadcast.emit('newProducer', { producerId: producer.id });
    
        callback({ id: producer.id });
    });
  3. 修改 consume 事件处理器:
    让它可以根据客户端请求的producerId,从producers Map中找到对应的生产者来消费。

    codeJavaScript
    // 'consume' 事件
    socket.on('consume', async (data, callback) => {
        const { rtpCapabilities, producerId, transportId } = data;
    
        const producer = producers.get(producerId); // <-- 修改:从Map中获取producer
    
        if (!producer) {
             console.error(`未找到要消费的Producer: ${producerId}`);
             return callback({ error: 'Producer not found' });
        }
        // ... 后续代码不变 ...
    });
  4. 修改 connect 时的逻辑 (重要!)
    当一个新的接收端连接上来时,它需要知道当前已经存在的所有生产者,而不仅仅是未来新产生的。

    codeJavaScript
    // io.on('connection', ...)
    io.on('connection', (socket) => {
        // ...
        
        // 当新客户端连接时,立即告诉它所有已存在的producer
        if (producers.size > 0) {
            const producerIds = [...producers.keys()];
            socket.emit('existingProducers', producerIds);
        }
    
        // ... 其他事件处理器 ...
    });
第二步:修改 receiver.js 以处理多个生产者

接收端需要处理existingProducers事件,并为每个已存在的生产者都创建一个消费逻辑。

codeJavaScript

// receiver.js
// ...
// 接收端连接成功后
socket.on('connect', async () => {
    // ... initialize() 照常调用 ...
});

// 新增:处理已存在的生产者列表
socket.on('existingProducers', (producerIds) => {
    console.log(`收到已存在的Producers列表:`, producerIds);
    producerIds.forEach(producerId => {
        consume(producerId);
    });
});

// 服务器通知有新的生产者 (这个逻辑保持不变)
socket.on('newProducer', ({ producerId }) => {
    console.log(`收到新Producer通知: ${producerId}`);
    consume(producerId);
});

async function consume(producerId) {
    // ... 消费逻辑 ...
    
    const { track } = consumer;
    // remoteVideo.srcObject = new MediaStream([track]); // <-- 这里需要修改!
    
    // --- 新的渲染逻辑,支持多视频 ---
    // 创建一个新的video元素并添加到页面上
    const newVideo = document.createElement('video');
    newVideo.id = `video-${producerId}`;
    newVideo.srcObject = new MediaStream([track]);
    newVideo.autoplay = true;
    newVideo.playsInline = true;
    newVideo.muted = true; // 多个视频通常需要静音
    document.getElementById('video-container').appendChild(newVideo);

    // ...
}
第三步:测试“多对多”
  1. 重启node app.js让服务器修改生效。

  2. 打开2个sender.html页面。

  3. 打开2个receiver.html页面。

  4. 在第一个sender页面开始共享。 -> 两个receiver页面都应该显示第一个sender的画面。

  5. 在第二个sender页面开始共享。 -> 两个receiver页面的video-container里,都应该会新增一个video元素,开始播放第二个sender的画面。

现在,你的接收端就可以同时看到来自多个发送端的视频了!这就是一个基本的多对多视频会议/监控系统的雏形。

第五步:http协议下访问共享功能的问题

默认情况下,Vite开发服务器只允许来自localhost(即本机)的访问。

这是一种安全措施,防止你局域网内的其他人无意中访问到你正在开发的、可能还不稳定的网站。


问题分析

  • http://localhost:5173/receiver.html 能访问

    • localhost 是一个特殊的主机名,它总是指向你自己的电脑 (127.0.0.1)。

    • 当你从本机的浏览器访问localhost时,Vite服务器看到了一个来自它所允许的地址的请求,所以正常响应了。

  • http://192.168.0.102:5173/receiver.html 无法访问

    • 192.168.0.102 是你在局域网中的IP地址。

    • 当你使用这个IP地址访问时,即使是从本机的浏览器,Vite服务器也会认为这是一个来自“外部网络”(相对于localhost而言)的请求。

    • 由于Vite默认没有配置为“对外开放”,它会拒绝这个连接,导致你的浏览器显示“无法访问此网站”或类似的错误。

解决方案:让Vite服务器“对外开放”

你需要明确地告诉Vite:“请监听所有网络接口的请求,不要只局限于localhost”。这可以通过添加一个--host参数来实现。

操作步骤
  1. 停止当前的Vite服务器:

    • 在你运行npm run dev的那个命令行窗口,按 Ctrl + C 来停止它。

  2. 修改package.json文件:

    • 打开你前端项目 web-frontend 目录下的 package.json 文件。

    • 找到 "scripts" 部分。

    • 在你现有的 "dev" 命令后面,添加 --host 参数。

    修改前可能是这样:

    codeJson
    "scripts": {
      "dev": "vite",
      "build": "vite build",
      "preview": "vite preview"
    },

    修改后应该是这样:

    codeJson
    "scripts": {
      "dev": "vite --host",
      "build": "vite build",
      "preview": "vite preview"
    },

    --host 参数也可以写成 --host 0.0.0.0,它们的效果是一样的,都是让服务器监听所有可用的网络接口。

  3. 重新启动Vite服务器:

    • 在web-frontend目录下,再次运行命令:

      codeBash
      npm run dev
  4. 查看新的输出信息:

    • 这次,当你启动Vite后,命令行输出会变得不一样,它会明确地告诉你局域网的访问地址:

      codeCode
      VITE v5.x.x  ready in xxx ms
      
        ➜  Local:   http://localhost:5173/
        ➜  Network: http://192.168.0.102:5173/  <-- 关键!现在多了这一行
        ➜  ...

你们还会遇到一个问题:

成功连接到信令服务器!
sender.js:113 开始共享失败: TypeError: Cannot read properties of undefined (reading 'getDisplayMedia')
at HTMLButtonElement.startSharing (sender.js:60:52)
startSharing @ sender.js:113

解决方案:

  1. 安装一个插件:
    Vite的核心功能不包含这个,但可以通过一个官方插件来实现。在 web-frontend 目录下,运行:

    codeBash
    npm install @vitejs/plugin-basic-ssl -D

    -D 表示这是一个开发依赖。

  2. 配置Vite:

    • 在web-frontend目录下,找到(或创建)一个名为 vite.config.js 的文件。

    • 将以下内容粘贴进去:

    codeJavaScript
    // vite.config.js
    import { defineConfig } from 'vite';
    import basicSsl from '@vitejs/plugin-basic-ssl';
    
    export default defineConfig({
      plugins: [
        basicSsl() // 启用basic-ssl插件
      ],
      server: {
        https: true, // 关键:开启HTTPS
        host: true,  // 关键:继续允许局域网访问
      }
    });

接着将你的IP地址标记为安全来源

这个设置项藏在浏览器的实验性功能标志(Flags)页面里。

操作步骤 (以Chrome为例,Edge类似)
  1. 打开浏览器的Flags页面:

    • 在Chrome或Edge的地址栏中,输入以下地址并回车:
      chrome://flags

  2. 搜索相关标志:

    • 在Flags页面的顶部搜索框中,输入 “insecure”。

  3. 找到并启用目标标志:

    • 你应该会看到一个名为 Insecure origins treated as secure 的选项。

    • 它的描述是:“将不安全的来源(origins)视为安全来源。可以使用逗号分隔的列表来指定多个来源。”

  4. 输入你的服务器地址:

    • 在这个选项右侧的输入框中,精确地输入你的Vite开发服务器的http地址(不需要路径,只需要 协议://主机:端口)。

    • 请填入:http://192.168.0.102:5173

    • 如果你有多个需要信任的地址,可以用逗号隔开。

  5. 启用并重启浏览器:

    • 在输入框下方的下拉菜单中,确保状态是 Enabled (通常在你输入内容后会自动变为Enabled)。

    • 页面底部会出现一个蓝色的 Relaunch (重启) 按钮。点击它,让浏览器重启以使设置生效。

到这里应该就全都完成了!

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐