今天算是把 EasyLive 这个项目正式从想法拉到了代码层面。简单交代一下背景:EasyLive 是我一直想做的一个轻量直播工具,目标是让非技术背景的主播也能在不折腾 OBS、不研究推流地址的情况下,打开就能开播。第一天我不指望它能推流到公网,只求把工程骨架立起来、摄像头画面能上屏、本地录制链路能跑通。这篇日记会把当天的需求判断、技术选型、实际写下的关键代码,以及踩过的几个坑完整记录下来。如果你也在做 WebRTC 相关的直播客户端,或者正在纠结怎么起步,这篇内容应该能帮你省掉不少试错时间。

很多人觉得做一个直播客户端,起点应该是先搞定服务器、信令、推流协议,但我的看法恰恰相反。直播链路是很长的,从采集、编码、推流到分发、播放,每一环都能让人陷进去一整周。如果第一天就扎进服务端,大概率到晚上还看不到一个“画面”,很容易挫伤士气。所以我把第一天定成“最小闭环验证日”:先证明采集端能拿到摄像头和麦克风、画面能在界面上实时显示、数据能落成本地文件。这些都是整个直播链路里最基础也最不能出错的部分,任何一环有问题,后面做推流和信令都会跟着翻车。

1. 项目立项与整体思路拆解

1.1 为什么做 EasyLive:直播间搭建的长期痛点

我观察到一个很普遍的现象:很多想尝试直播的创作者,卡在的不是内容,而是“开播”这个动作本身。装 OBS、配场景、填推流地址、调码率、选编码器,再理解一遍分辨率、帧率、关键帧间隔这些概念,基本已经劝退了大半人。平台自带的直播助手虽然简化了流程,但功能固化得厉害,想做点个性化的画面布局或者接入自己的业务系统,几乎不可能。

EasyLive 的出发点就是把这个“开播门槛”压到最低。它更像一个“开播壳子”:自带一套合理的默认参数,打开就能用;同时把常用的设置项以面板形式暴露出来,而不是藏在一层一层的菜单里。从第一天起,我就不打算把它做成又一个功能臃肿的 OBS 换皮,而是做成一个为“临时开播”“知识分享”“小型活动直播”这些轻量场景服务的快捷工具。

1.2 产品边界:第一版明确不做的事

立项时最重要的事不是想加什么功能,而是想清楚砍掉什么功能。第一天产品边界我列得很粗暴:不做多机位切换,不做虚拟背景,不做复杂场景布局,不做弹幕互动,不做录像回放合成,不做跨平台移动端。这个阶段只有一个主线,就是“单机位画面 + 麦克风声音先能顺畅地走通”。

为什么这么激进?因为直播客户端的复杂度通常不是来自单点技术,而是来自各种功能组合之后的连携问题。比如虚拟背景要和美颜共用 GPU 资源,多机位切换涉及画面缓冲和预加载,弹幕互动要对接平台的 WebSocket 协议,这些功能每一个单拎出来都能做,叠在一起就会让第一天的代码量翻好几倍,也让我没法快速验证最核心的采集链路。EasyLive 以后如果要加这些能力,也应该在采集和本地渲染都稳定之后再做增量,而不是在第一天就铺开。

1.3 目标用户与核心使用场景

我对 EasyLive 的预想用户有三类:第一类是知识博主,需要一边演示文档或代码一边露脸讲解,她们需要的是“别让我配置、快点开始录”;第二类是小微企业的运营同学,偶尔要做一场内部培训或产品说明会,不想让 IT 部门专门搭环境;第三类是有一定开发能力的个人用户,拿到这个开源壳子以后,可以基于它快速改成自己品牌的直播间。这三类人的共同点是:不关心推流协议细节,但非常在乎开播体验的顺滑度。

对应到产品形态上,EasyLive 的第一版应该是一个桌面应用,而不是网页。原因很简单:桌面应用可以更自然地拿摄像头权限、占用系统资源时更可控、也方便后续集成系统级的音频采集和硬件编码。选桌面这条路基本确定了技术栈的方向,后面所有设计都围绕它展开。

2. 技术选型与架构设计

2.1 客户端框架选型:为什么从 Electron 起步

桌面应用框架选择上,我认真比较过三个方向:原生方案(C++/Qt 或 C#/WinUI)、Tauri、Electron。原生方案的性能和体积确实最好,但音视频这块要自己封装的东西太多,光是跨平台的摄像头抽象、音频设备枚举和硬件编码适配就够写几个月的;Tauri 很轻,Rust 后端 + Web 前端,但对于 WebRTC 这种重度依赖 Chromium 内核能力的场景,Tauri 目前在移动端和桌面端的媒体能力支持还比较零散,遇到问题需要深入 Rust 侧去调,迭代速度不会太快。

最终选 Electron 不是因为它的性能最好,而是它踩坑成本最低。Electron 内置了完整 Chromium, getUserMedia 、WebRTC、H.264 编解码、甚至未来的 WebCodecs,全部可以直接用,不需要自己维护原生模块。对于第一天的目标——快速看到画面、快速验证链路——Electron 是投入产出比最高的选择。等业务规模大了、用户对安装包体积和内存占用有意见了,再考虑往 Tauri 迁移也不迟,前提是迁的时候不要把业务逻辑和 UI 耦合在 Electron 的 API 上。

2.2 采集与传输链路:从 getUserMedia 到 WHIP 的路径规划

直播链路的传输协议我计划用 WHIP(WebRTC-HTTP Ingestion Protocol)而不是传统的 RTMP。WHIP 本质上就是通过 HTTP POST 创建一个 WebRTC 会话,然后直接用 SRTP 推流,相比 RTMP 有更低的延迟、更好的 NAT 穿透能力,而且天然的端到端加密。虽然现在很多直播平台还在用 RTMP,但新的低延迟直播场景里 WHIP 的接受度正在快速提升,直播服务商像 Cloudflare、Mux 都已经支持 WHIP 接入。

不过第一天我不会直接接 WHIP。WHIP 链路至少需要三块东西:客户端采集与 PeerConnection 管理、信令服务、服务端转发组件。第一天硬接 WHIP 意味着还要同时写信令服务,这会打乱“最小闭环”的节奏。所以我把传输分成两个阶段:本地阶段先用 MediaRecorder 把采集到的流录制为 WebM 文件,验证采集端的数据是真实可用的;下一阶段再做真正的 WebRTC 推流。

2.3 服务端组件规划与第一天的取舍

EasyLive 的服务端最终会有三个组件:信令服务(协调 WebRTC 的 SDP 交换)、媒体中继(SFU 或简单转发)、房间管理 API。这是一个典型的 WebRTC 直播后端。但第一天我明确不写服务端代码,甚至不会为此建一个后端仓库。原因有两个:第一,服务端要联调必须有一个能跑的客户端,否则写出来的接口都是自嗨;第二,服务端的技术选型(Node.js 还是 Go、SFU 用 mediasoup 还是 ion-sfu)会直接影响后续推流体验,我想等客户端全程跑通之后再做这个决定。

所以我第一天的架构里,服务端只是图纸上的一个虚线框,标注着“后续接入”。整个工程只包含一个 Electron 主进程和一个渲染进程,数据流也就是一个单向闭环:摄像头/麦克风经过 getUserMedia 进入 MediaStream,然后一路给 video 标签预览,一路给 MediaRecorder 录制。这个闭环虽然简单,但它是后续一切传输逻辑的基座。

2.4 工程目录设计与初始化

工程结构我按 Electron + React + TypeScript 的常规方案来组织,但不引入太重度的脚手架,避免第一天就冒出来一堆看不懂的抽象层。目录设计得很直白:

easylive/
├── package.json
├── tsconfig.json
├── src/
│   ├── main/                 # Electron 主进程
│   │   ├── index.ts
│   │   └── permission.ts     # 摄像头/麦克风权限管理
│   ├── preload/
│   │   └── index.ts
│   └── renderer/             # React 渲染进程
│       ├── App.tsx
│       ├── components/
│       │   ├── Preview.tsx
│       │   ├── ControlPanel.tsx
│       │   └── SettingsPanel.tsx
│       └── hooks/
│           └── useMediaStream.ts
└── resources/                # 图标、默认配置等静态资源

初始化用的命令就是常规的 npm create vite@latest 选 React-TS 模板,然后再手动加 Electron 相关依赖。这个组合已经被无数项目验证过,第一天的目的不是搞架构创新,而是把试错成本控制在最低。

3. 第一天实操:从零到可预览的摄像头上屏

3.1 初始化 EasyLive 工程与基础依赖

工程初始化的命令序列我不写得过于琐碎,直接给结果。依赖分两部分:运行时依赖和开发依赖。

npm install react react-dom
npm install -D electron vite @vitejs/plugin-react typescript electron-builder concurrently wait-on

Electron 和 Vite 的整合有个小窍门:开发环境下让 Vite 跑在 5173 端口,Electron 主进程用 loadURL 加载这个地址;生产环境再用 loadFile 加载打包后的静态文件。这样开发时能直接用 Vite 的热更新,不需要每次改 UI 都重启 Electron。 package.json 里的关键脚本长这样:

{
  "scripts": {
    "dev:vite": "vite",
    "dev:electron": "wait-on tcp:5173 && electron .",
    "dev": "concurrently -k \"npm:dev:vite\" \"npm:dev:electron\"",
    "build": "tsc && vite build && electron-builder",
    "start": "electron ."
  },
  "main": "dist/main/index.js"
}

这里有个容易踩的坑:Electron 默认会去 package.json 的 main 字段找入口文件,如果你用的是 TS 源码,直接写 src/main/index.ts 会导致运行时报错。第一天我直接通过 tsc 把主进程代码编译到 dist/main/ 下,再让 main 指向编译产物,这样跑起来最省心。渲染进程的 Vite 构建产物放在 dist/renderer/ ,两边互不干扰。

3.2 摄像头采集:getUserMedia 的参数与权限处理

采集是整个 EasyLive 最核心的起点,所有后续链路都建立在这条 MediaStream 上。我封装了一个 useMediaStream Hook 来做这件事,核心逻辑就是调用 getUserMedia ,把返回的 MediaStream 挂到 React 的状态里。

// src/renderer/hooks/useMediaStream.ts
import { useCallback, useEffect, useRef, useState } from "react";

export interface MediaSettings {
  resolution: "720p" | "1080p";
  frameRate: number;
}

const RESOLUTION_MAP = {
  "720p": { width: { ideal: 1280 }, height: { ideal: 720 } },
  "1080p": { width: { ideal: 1920 }, height: { ideal: 1080 } },
};

export function useMediaStream(settings: MediaSettings) {
  const [stream, setStream] = useState<MediaStream | null>(null);
  const [error, setError] = useState<string | null>(null);
  const streamRef = useRef<MediaStream | null>(null);

  const start = useCallback(async () => {
    try {
      if (streamRef.current) {
        streamRef.current.getTracks().forEach((track) => track.stop());
      }
      const constraints: MediaStreamConstraints = {
        video: {
          ...RESOLUTION_MAP[settings.resolution],
          frameRate: { ideal: settings.frameRate, max: settings.frameRate + 5 },
          facingMode: "user",
        },
        audio: {
          echoCancellation: true,
          noiseSuppression: true,
          autoGainControl: true,
          channelCount: 1,
        },
      };
      const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
      streamRef.current = mediaStream;
      setStream(mediaStream);
      setError(null);
    } catch (err) {
      const name = (err as DOMException).name;
      if (name === "NotAllowedError") {
        setError("摄像头/麦克风权限被拒绝,请在系统设置中允许授权");
      } else if (name === "NotFoundError") {
        setError("未检测到可用摄像头或麦克风设备");
      } else {
        setError(`采集失败:${(err as Error).message}`);
      }
    }
  }, [settings.resolution, settings.frameRate]);

  const stop = useCallback(() => {
    if (streamRef.current) {
      streamRef.current.getTracks().forEach((track) => track.stop());
      streamRef.current = null;
      setStream(null);
    }
  }, []);

  useEffect(() => {
    return () => {
      if (streamRef.current) {
        streamRef.current.getTracks().forEach((track) => track.stop());
      }
    };
  }, []);

  return { stream, error, start, stop };
}

这里一个重要的细节是:重新点击“开始采集”之前,必须先把上一次的 track 全部 stop() ,否则镜头上会同时存在两个亮着的指示灯,很多用户会以为设备被两个程序占用了。另一个细节是 facingMode: "user" ,这个字段在桌面浏览器上通常没有效果,但保留它可以让同一套代码以后迁移到移动端时依然能用前置摄像头,算是提前打个底。

3.3 媒体流上屏:srcObject 的正确使用方式

拿到 MediaStream 后,下一步是让它显示在界面上。React 里给 video 标签绑定媒体流有个常见的坑:直接设置 src 属性是行不通的,必须用 srcObject 属性,而且 srcObject 是一个 DOM 属性,不是 HTML 属性,在 JSX 里需要用小写驼峰写法 srcObject 。

// src/renderer/components/Preview.tsx
import { useEffect, useRef } from "react";

interface PreviewProps {
  stream: MediaStream | null;
}

export function Preview({ stream }: PreviewProps) {
  const videoRef = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    if (videoRef.current && stream) {
      videoRef.current.srcObject = stream;
    }
  }, [stream]);

  return <video ref={videoRef} autoPlay muted playsInline className="preview-video" />;
}

为什么 muted 这么关键?这又是一个让很多新手摸不着头脑的点:如果 video 标签不设置 muted ,浏览器为了防骚扰,通常会拦截带声音的自动播放。也就是说,画面可能出不来,你必须手动点一下播放按钮才继续。但在直播场景里,声音是要通过扬声器外放的,理想状态是画面和声音同时出现。暂时把标签设为 muted ,是因为第一天我们用不到本地监听(不然能听到自己的回声),后面如果要做监听功能,再单独加一个播放器的切换逻辑。至于 playsInline ,则是兼容 iOS Safari 的策略,桌面端虽然不影响,但加上无妨。

3.4 可用性验证:用 MediaRecorder 录制一段本地视频

摄像头画面能上屏只是“能看”,还不能证明数据链路是闭环可用的。第一天我给链路加了一个最务实的验证手段:用 MediaRecorder 把实时流录成 WebM 文件,然后下载下来用播放器打开检查。这一步的本质是在没有服务器的情况下,验证“采集、编码、封装”这一段管线的完整性。

// src/renderer/hooks/useRecorder.ts
import { useCallback, useRef, useState } from "react";

export function useRecorder() {
  const recorderRef = useRef<MediaRecorder | null>(null);
  const chunksRef = useRef<Blob[]>([]);
  const [recording, setRecording] = useState(false);
  const [recordTime, setRecordTime] = useState(0);

  const start = useCallback((stream: MediaStream) => {
    const mimeType = MediaRecorder.isTypeSupported("video/webm;codecs=vp9")
      ? "video/webm;codecs=vp9"
      : "video/webm";
    const recorder = new MediaRecorder(stream, {
      mimeType,
      videoBitsPerSecond: 2_500_000,
      audioBitsPerSecond: 128_000,
    });

    chunksRef.current = [];
    recorder.ondataavailable = (event) => {
      if (event.data.size > 0) {
        chunksRef.current.push(event.data);
      }
    };

    recorder.onstop = () => {
      const blob = new Blob(chunksRef.current, { type: mimeType });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = `easylive-${Date.now()}.webm`;
      a.click();
      URL.revokeObjectURL(url);
      chunksRef.current = [];
      setRecording(false);
      setRecordTime(0);
    };

    recorder.start(1000);
    recorderRef.current = recorder;
    setRecording(true);
    const timer = window.setInterval(() => {
      setRecordTime((t) => t + 1);
    }, 1000);

    // 简单用一个变量存 timer,实际项目里建议放到 ref
    (recorder as unknown as { __timer?: number }).__timer = timer;
  }, []);

  const stop = useCallback(() => {
    if (recorderRef.current && recorderRef.current.state !== "inactive") {
      recorderRef.current.stop();
      const timer = (recorderRef.current as unknown as { __timer?: number }).__timer;
      if (timer) window.clearInterval(timer);
    }
  }, []);

  return { start, stop, recording, recordTime };
}

录制的重点参数是 recorder.start(1000) 里的 1000 ,它表示每秒钟触发一次 ondataavailable ,也就是把数据按 1 秒切片。这个值很有讲究:切得太小(比如 100ms)会产生大量小分片,封装效率低;切得太大(比如 5000ms),一旦录制中崩溃,最后一次切片的数据可能全丢。1 秒是一个在容错性和封装效率之间比较均衡的选择。

3.5 第一版 UI 骨架:开播面板设计

界面方面,第一天我只花了一个多小时做最小可用的布局,重点不在视觉,而在把操作路径走通。整体分三块:左侧是预览区,右侧是控制面板。控制面板从上到下依次是:开始采集按钮、开始录制/停止录制按钮、分辨率选择下拉框(720p / 1080p)、帧率选择(25 / 30 / 60),以及当前的录制计时。

// src/renderer/App.tsx
import { useState } from "react";
import { Preview } from "./components/Preview";
import { ControlPanel } from "./components/ControlPanel";
import { useMediaStream } from "./hooks/useMediaStream";
import { useRecorder } from "./hooks/useRecorder";

export default function App() {
  const [resolution, setResolution] = useState<"720p" | "1080p">("720p");
  const [frameRate, setFrameRate] = useState(30);
  const { stream, error, start, stop } = useMediaStream({ resolution, frameRate });
  const recorder = useRecorder();

  const handleStartStream = async () => {
    await start();
  };

  const handleStopStream = () => {
    if (recorder.recording) {
      recorder.stop();
    }
    stop();
  };

  const handleToggleRecord = () => {
    if (!stream) return;
    if (recorder.recording) {
      recorder.stop();
    } else {
      recorder.start(stream);
    }
  };

  return (
    <div className="app-container">
      <div className="preview-area">
        <Preview stream={stream} />
        {!stream && <div className="placeholder">点击"开始采集"后显示预览画面</div>}
        {error && <div className="error-text">{error}</div>}
      </div>
      <ControlPanel
        stream={stream}
        recording={recorder.recording}
        recordTime={recorder.recordTime}
        resolution={resolution}
        frameRate={frameRate}
        onChangeResolution={setResolution}
        onChangeFrameRate={setFrameRate}
        onStartStream={handleStartStream}
        onStopStream={handleStopStream}
        onToggleRecord={handleToggleRecord}
      />
    </div>
  );
}

这里有一个交互设计的小考虑:当用户修改分辨率和帧率时,我并不要求立刻生效,而是等用户下一次点击“开始采集”才用新参数重新拉流。这样避免用户在拖动下拉框时摄像头反复重启,也避免录制过程中参数抖动导致录制文件损坏。这是从 OBS 的“应用按钮”机制借鉴过来的,只不过简化成重新拉流一次。

4. 第一天踩坑与排查记录

4.1 摄像头在 Electron 里拿不到:权限应该在主进程配置

第一个坑非常有代表性:浏览器里跑 getUserMedia 一切正常,但进了 Electron 就报 NotAllowedError 。排查了许久才发现,Electron 有一套与浏览器不同的权限模型,默认情况下渲染进程请求摄像头权限时,即便用户点了允许,也可能因为主进程没有配置对应的 session 权限策略而失败。解决办法是在主进程创建窗口时显式放行媒体权限。

// src/main/index.ts
import { app, BrowserWindow, session } from "electron";

app.whenReady().then(async () => {
  await session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
    if (permission === "media" || permission === "display-capture") {
      callback(true);
    } else {
      callback(false);
    }
  });

  const win = new BrowserWindow({
    width: 1280,
    height: 800,
    webPreferences: {
      preload: path.join(__dirname, "../preload/index.js"),
      contextIsolation: true,
      nodeIntegration: false,
    },
  });

  win.loadURL(process.env.VITE_DEV_SERVER_URL || `file://${path.join(__dirname, "../renderer/index.html")}`);
});

setPermissionRequestHandler 里的 permission === "media" 就对应 getUserMedia 的摄像头与麦克风请求。如果你用的是更现代版本的 Electron,还可以用 setDevicePermissionHandler 做更细粒度的设备级别控制。这个问题的排查难点在于报错信息不直观,你只会看到一个笼统的 NotAllowedError ,感觉像用户拒绝了权限,但实际上代码里根本没人弹过权限窗口。所以碰到 Electron 的媒体权限问题,优先去检查主进程的 permission handler,而不是折腾渲染进程的约束条件。

4.2 本地预览黑屏:srcObject 和 autoplay 的组合陷阱

第二个坑是预览区域黑屏,但控制台没有任何报错。这种情况十有八九是 srcObject 赋值成功但 video 没有触发播放。单独设置 srcObject 并不会自动开始播放,你还需要配合 autoPlay ,而且如果 video 是有声的,浏览器可能还是不给自动播。我的解决办法是前三板斧一起上: autoPlay 、 muted 、 playsInline ,前面代码里已经体现。

还有一个容易忽略的细节: useEffect 的执行时机。如果把 srcObject 赋值写在 useEffect 里,而 stream 状态更新后又导致组件重渲染,理论上 useEffect 会重新执行,应该没问题。但如果你不小心给 video 同时设置了 src 属性(哪怕是 src="" ),也会导致 srcObject 被覆盖成空。所以检查黑屏问题时,先去 DOM 面板确认 video 元素上有没有异常的多余属性。

4.3 声音发闷或爆音:音频约束的工程取舍

第一天测试录制文件时,我感觉声音有点闷,人声不够清晰。这个问题的根子不在编码,而在采集时的音频约束。 getUserMedia 的音频参数里, echoCancellation 、 noiseSuppression 、 autoGainControl 这三个默认值在不同平台上的行为不一致。我的目标场景是“人声为主”,所以开启了回声消除和降噪,但关掉了自动增益控制。

这里解释一下为什么关掉 autoGainControl :自动增益会把音量自动拉到一个固定的响度水平,听起来音量是稳了,但说话时的气息和停顿也会被放大,导致声音有一种“压缩感”甚至“泵感”,对直播人声反而不利。我更倾向于让主播在系统层面把麦克风音量调到合适的水平,采集端不做过多干预。当然,这个取舍不是绝对的,如果你的用户群体是游戏主播或环境噪音较大的场景,开启 autoGainControl 效果反而更好。EasyLive 后续会把这个选择做成一个可配置项,但第一天默认关掉。

4.4 视频编码参数怎么定:码率分辨率对应关系

视频编码参数的默认值直接决定录制文件的大小和清晰度,我整理了第一天实际验证下来比较合适的参数组合,后续做推流时可以直接沿用这套经验值。

分辨率 帧率 建议视频码率 适用场景
720p 25 1.5 - 2 Mbps 纯语音分享、PPT 直播
720p 30 2 - 2.5 Mbps 通用默认,当前 EasyLive 首选
1080p 30 4 - 6 Mbps 动作较快或画面细节较多的场景
1080p 60 6 - 8 Mbps 游戏直播、体育类画面

这里有一个新手常犯的认知错误:分辨率不是越高越好。1080p 的原始画面数据量非常大,如果不能提供足够的码率,画面会出现大量的块状噪声和模糊,观感反而不如 720p。我第一天的默认值是 1280x720 + 30fps + 2.5Mbps ,这个组合在大多数网络条件和多数内容类型下都能有比较稳定的表现。后续接入 WHIP 推流时,还会把码率改成自适应算法,但本地录制场景就用固定码率,稳定优先。

4.5 第一天问题速查表

把当天遇到的问题整理成一张速查表,方便以后排查同类故障时快速定位。这里直接给出实际结论,不做理论铺垫。

现象 可能原因 解决方案
点击采集后无任何提示,摄像头灯不亮 Electron 主进程未配置媒体权限 在 main 进程调用 setPermissionRequestHandler 放行 media
画面黑屏但无报错 video 未触发自动播放,或 srcObject 被 src 覆盖 设置 autoPlay muted playsInline ,移除多余 src
录制文件无画面但有声音 视频 track 未成功编码,常见于 H.264 支持不完整 尝试切换到 vp9 编码, video/webm;codecs=vp9
声音有电音或刺耳感 自动增益导致失真 将 autoGainControl 设为 false
切换分辨率后没变化 采集约束未生效,需要重新调用 getUserMedia 点击“重新采集”,先 stop 旧 track 再拉新流
关闭窗口后摄像头灯仍亮 渲染进程销毁时未停止媒体轨道 在组件卸载的 useEffect 清理函数中调用 track.stop()

这里要特别说下第六个问题。很多人开发时只关注启动逻辑,却忘了清理逻辑。摄像头是一个硬件资源,如果在关闭窗口时不主动释放,虽然进程退出后系统最终会回收,但在调试模式下窗口关闭但主进程不退出的场景下,摄像头会被一直占着,第二次启动时就会出现抢不到设备的情况。所以我要求初始代码里的 useMediaStream Hook 必须在 useEffect 中返回一个清理函数,把每个 track 都 stop 掉。这是一个很小但很重要的工程习惯。

5. 第一天的复盘与后续规划

5.1 今日成果与遗留问题

到晚上收工时,EasyLive 已经能完成这样一件事:启动应用、点击“开始采集”、摄像头画面出现在预览区、点击“开始录制”、等待几秒后停止,生成一个可播放的 WebM 文件。这个流程虽然和真正的直播还有很大距离,但它证明了采集端到编码封装端的数据通路是通畅的。这个基础如果不稳,后面所有的网络传输都是空中楼阁。

遗留的问题也清晰可见:首先是 MediaRecorder 的延迟问题,录制模式下画面有明显的缓冲滞后,这个在直播间场景是不可接受的,后续推流必须换成 WebRTC 的实时传输,而不是本地录制;其次是 UI 还很粗糙,没有做响应式布局,也没有做键盘快捷键;再有就是错误提示不够完善,比如设备热插拔的情况还没有监听,USB 摄像头拔掉再插上,界面不会自动刷新设备列表。

5.2 第二天要做的三件事

第二天的计划很简单,就三件事:第一,搭建一个最小的信令服务,用 Node.js 实现,职责只有两个,接收客户端的 offer 并转发给服务端,以及把服务端的 answer 回传给客户端,不引入数据库、不做鉴权、不做房间状态管理;第二,在客户端接入原生 WebRTC 的 RTCPeerConnection ,把本地采集流通过 WHIP 协议推给一个本地测试服务,验证真实的网络传输链路;第三,做设备枚举和设备插拔监听,让用户在摄像头被占用或拔掉时能得到明确的提示。

这里有个值得提醒的点:信令服务虽然是第二天才做,但客户端的设计必须预留扩展点。我第一天写的 useMediaStream 返回的 stream 对象,后续可以直接作为 RTCPeerConnection.addTrack() 的输入,不需要调整采集层代码。这就是为什么第一天即使不做推流,仍然要认真设计采集层的接口。直播客户端的模块边界比功能完成度更重要,后面接入任何新能力都会依赖这个边界。

5.3 一点个人体会

项目日记这种形式,我最近越来越觉得有价值。很多项目做了一段时间后回头看,最大的问题是记不清当时为什么做某个决策。写日记不光是记录代码和进度,更是把“当时是怎么想的”留下来。比如第一天选择不做服务端、不接 WHIP,如果没有文字记录,两周后我八成会质疑自己为什么第一天效率这么低。但有了这个日记,我就能清楚地回溯当时的上下文:第一天最重要的任务是验证采集和本地渲染,而不是提前铺开网络模块。后续开发遇到“我当初为什么要这样设计”的困惑时,翻翻日记就能找到答案,也避免了无谓地推翻重来。

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐