📍 词元二号站 AI互联网 从零做一个本地录屏工具:屏幕采集、双路音频混流与编码落盘全链路拆解(附 AI 编程档位选择与用量成本控制)

从零做一个本地录屏工具:屏幕采集、双路音频混流与编码落盘全链路拆解(附 AI 编程档位选择与用量成本控制)

摘要:一篇完整的本地录屏工具开发拆解:讲清屏幕采集接口、麦克风与系统声音的双路混流原理、编码容器选择与时长元数据修补,并给出可复用的 AI 编程需求模板、模型档位选择策略与 Credits 用量控制方法,附十条踩坑排查清单。
字号 100%
行距 2.05
当前可见 35% 的内容
本文由 辛梓煜@词元2号站(www.ciyuanerhao.com)撰写,转载请注明出处。

快速摘要

一个真正能用的桌面录屏工具,难点从来不在界面,而在三件事:画面要靠浏览器的屏幕捕获接口拿;系统声音和麦克风必须先用 Web Audio API 混成一条音轨,录制器才认;录完的 WebM 文件默认缺时长元数据,不处理就会出现"进度条拖不动"。把这三点提前写进需求文档,再交给支持深度推理的 AI 编程档位去跑,从一个空文件夹到能跑起来的完整项目目录,通常一两轮对话就有雏形,剩下的时间基本都花在权限差异和状态机边界上。用量方面,按 Qoder 官方文档的口径:2026 年 7 月 30 日 23:59(UTC+8)之前,所有个人用户可在客户端内领取 200 次「极致(Ultimate)」档位免费调用;领取之后,付费用户、历史付费用户以及官方定向发放的用户会自动获得 1000 次加量包,且该额度会不定期重置。模型侧,Qwen3.8-Max-Preview 的 Credits 计费系数由 0.5× 降至 0.05×,每晚 22:00 到次日 08:00 的错峰时段再降到 0.01×。

下面我会把整条链路拆开讲清楚:每个接口到底在干什么、为什么必须那样接、需求该怎么写才能让模型一次做对、我自己踩过哪些坑、以及怎么在不牺牲效果的前提下把用量压下来。想看完整拆解,往下翻。


一、为什么"做一个录屏工具"是检验 AI 编程能力的好题目

1.1 现成录屏软件的三个通病

前阵子我重装了一次系统,临时要录一段操作演示发给朋友,翻了半天发现手头居然没有一个顺手的录屏工具。这事说起来有点荒诞——录屏是个二十年前就解决了的需求,但今天你去找一个"打开就能用"的,仍然要在三个坑之间反复横跳。

第一个坑是体积。很多老牌录屏软件动辄几百兆,装完还带一堆驱动和后台服务,为了录三分钟视频,系统托盘里多出两个常驻进程。

第二个坑是功能与需求错配。你只想要"选个窗口、点开始、点停止、保存到本地",结果软件先弹一个引导页问你要不要开通云空间,再问你要不要装配套的剪辑套件。

第三个坑是输出不受控。有的工具默认给你压成一种奇怪的编码,有的给你打上水印,有的限制单次时长。真正需要交付的时候,这些限制全是硬伤。

所以我干脆自己做一个:只做录屏这一件事,界面干净、参数可调、文件落在本地、双击就能跑。

1.2 这个需求的技术复杂度画像

如果只是做一个静态页面,那没什么好聊的。录屏工具之所以值得拿来当"试金石",是因为它把好几类不同性质的技术点压在了一个不大的工程里:

  • 实时媒体流处理:屏幕、麦克风、摄像头三路输入,需要在浏览器/运行时里同时握住。
  • 音频图(audio graph)编排:两路音频要在内存里实时混合,这是纯 Web Audio 的活儿。
  • 编码与容器格式:编码器选什么、容器选什么、码率帧率怎么给,直接决定文件体积和兼容性。
  • 权限模型:不同操作系统对"录屏""录系统声音"的授权路径完全不一样。
  • 状态机管理:空闲、倒计时、录制中、已暂停、已停止、预览中、已保存——七种状态之间的迁移必须严丝合缝,少一条边就会出现按钮点了没反应。
  • 异常与资源回收:用户中途撤销授权、磁盘写满、设备被拔掉,每一种都得有兜底。

单看每一项都不算难,但把它们缝在一起,就是典型的"工程题"而不是"算法题"。很多模型在这类任务上的表现是:前面写得像模像样,目录结构、界面样式都挺漂亮,一到录制状态切换、音频混流、文件导出这几个环节,就开始丢上下文——前面定义的状态枚举,后面自己就不用了。

这也是我为什么用它来对比不同 AI 编程工具的原因:它足够小,一个下午能验完;又足够复杂,能把"长链路任务保持一致性"的能力差异照出来。

1.3 我给自己定的验收标准

动手之前我先把验收标准写死了,一共八条,任何一条不过就算没做完:

编号

验收项

判定方式

A1

能选择"整个屏幕"或"某个具体窗口"

授权面板中能看到窗口列表

A2

能同时录到麦克风和系统声音

播放录制结果,两路声音都在

A3

支持摄像头画中画

成片右下角有摄像头画面

A4

支持倒计时开始

点开始后有 3 秒读秒

A5

支持暂停/继续,且时长统计正确

暂停 10 秒后继续,成片时长不含这 10 秒

A6

分辨率与帧率可切换

切换后文件属性符合预期

A7

录完能就地预览,不满意可丢弃

预览窗能播放、能删除重录

A8

保存到本地任意目录

弹出系统保存框,文件可用

有了这张表,后面无论是自己写还是让 AI 写,都有一个客观的收敛点,不会陷入"看起来做完了但其实处处是坑"的状态。


二、把录屏工具拆开看:采集 → 混流 → 编码 → 落盘

这一节是全文最硬的部分。把这条链路理解透,你就算不用任何 AI 工具,也能自己把它写出来;理解不透,AI 给你写完你也不知道哪里可能出问题。

2.1 先看整体数据流

一句话概括:三路输入 → 一路音频混合 → 与视频轨合并成一条流 → 交给录制器 → 输出二进制块 → 拼成文件落盘

flowchart LR
    A["屏幕/窗口<br/>getDisplayMedia"] -->|视频轨| M["合成 MediaStream"]
    A -->|系统声音轨| X["AudioContext<br/>混音节点"]
    B["麦克风<br/>getUserMedia"] -->|音频轨| X
    C["摄像头<br/>getUserMedia"] -->|视频轨| P["画中画合成<br/>Canvas"]
    P --> M
    X -->|单条混合音轨| M
    M --> R["MediaRecorder<br/>编码器"]
    R -->|ondataavailable| Q["Blob 分片队列"]
    Q --> S["合并 → 修元数据 → 保存"]

下面逐段拆。

2.2 画面从哪来:屏幕捕获接口

浏览器侧拿屏幕画面的标准入口是 navigator.mediaDevices.getDisplayMedia()。这个方法会提示用户选择并授权要捕获的显示内容,可以是整个屏幕,也可以是某一个应用窗口或某一个标签页,返回的是一个 MediaStream 对象。

它有两个特性值得先记住:

第一,它一定会给你视频轨。即使你传入的约束对象里没有显式请求视频,返回的流里也会带一条视频轨——这是规范定的,因为"屏幕捕获"本身的语义就是取画面。

第二,它必须由用户手势触发,且必然弹出选择面板。你没法绕过这个面板去静默录屏,这是出于隐私安全的硬性设计。所以产品上要接受一个事实:用户点"开始录制"之后,还得再点一次系统面板里的"共享",中间这一步是删不掉的。

最小可用的写法长这样:

// 请求屏幕画面。cursor 控制鼠标指针是否入镜
async function captureScreen({ width, height, frameRate }) {
  return await navigator.mediaDevices.getDisplayMedia({
    video: {
      cursor: 'always',
      width:  { ideal: width },
      height: { ideal: height },
      frameRate: { ideal: frameRate, max: 60 },
    },
    audio: true, // 关键:这里的 audio 指的是"系统/标签页声音",不是麦克风
  });
}

注意那个 audio: true。很多人第一次写会以为它是麦克风,其实不是——在屏幕捕获的语境下,它代表的是被捕获内容自身发出的声音(整屏共享时是系统声音,标签页共享时是该标签页的声音)。麦克风要单独走另一个接口。

术语解释一下MediaStream(媒体流)可以理解成一个"轨道容器",里面装着若干条 MediaStreamTrack(轨道),每条轨道要么是视频要么是音频。后面所有的操作,本质上都是在往这个容器里增删轨道,或者把几个容器里的轨道重新组装成一个新容器。

如果你做的是桌面应用(比如用 Electron 这类框架把网页壳成客户端),还有另一条路:主进程里用 desktopCapturer.getSources() 枚举出屏幕和窗口列表,自己画一个选择界面,选中之后把对应的源 id 传给渲染进程去起流。好处是选择面板可以完全自定义,坏处是要处理各平台的权限差异。辛梓煜@词元2号站这边实测下来,如果只是自用小工具,直接用标准的屏幕捕获接口就够了,自定义源列表属于锦上添花。

2.3 声音从哪来:两路,而且性质完全不同

录屏工具里的"声音"永远是两路,且来源、权限、平台限制都不一样:

第一路是麦克风,走 navigator.mediaDevices.getUserMedia({ audio: true })。这一路最简单,各平台行为一致,用户授权一次就好。真正要操心的是降噪、回声消除这些约束项:

const micStream = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true,   // 回声消除:录教程时开着,避免扬声器声音被麦克风二次收录
    noiseSuppression: true,   // 噪声抑制
    autoGainControl: false,   // 自动增益:录音乐/演示建议关掉,避免忽大忽小
  },
});

第二路是系统声音,它是跟着屏幕流一起来的,也就是上一节 getDisplayMedia 里那个 audio: true。这一路的坑集中在平台差异上:

平台

系统声音可获取性

说明

Windows

较好

共享"整个屏幕"时可勾选"分享系统音频",标签页共享也支持

ChromeOS

较好

与 Windows 类似

macOS

受限

系统层面长期不允许应用直接抓取输出音频,早期方案需要签名内核扩展;较新的系统版本才提供了官方接口

Linux

依赖音频子系统

通常需要音频服务端配好回环路由

在 macOS 上如果拿不到系统声音,常见的绕行办法是装一个虚拟音频设备,把系统输出路由到这个虚拟设备,再当作一个普通输入设备去采集。这条路能走通,但对普通用户来说门槛太高——所以我的处理是:检测不到系统声音就在界面上明确提示"当前系统下仅录制麦克风",而不是静默失败。用户知道发生了什么,比工具假装一切正常要好得多。

2.4 为什么必须混流:录制器只认一条音轨

这是整个项目里最反直觉的一点,也是最容易翻车的地方。

直觉上你会想:既然有麦克风轨和系统声音轨,那我把两条音频轨都塞进同一个流,交给录制器不就行了?

不行。 主流实现的媒体录制接口在输出时只会处理一条音轨。而且即便真的写进去两条,容器里的多音轨语义类似于影碟的多国语言配音——同一时刻只有一条在播,不是叠加播放。

所以正确解法是:在录制之前,先用 Web Audio API 把两路声音实时混合成一路。核心就三个 API:

  • AudioContext:音频处理的上下文,可以理解成一块"调音台"。
  • createMediaStreamSource(stream):把一条媒体流接进调音台,变成一个输入通道。
  • createMediaStreamDestination():在调音台上开一路输出,这路输出本身又是一条媒体流。

把两个输入都连到同一个输出上,混音就完成了:

/**
 * 把系统声音与麦克风混成一条音轨
 * @returns {MediaStreamTrack|null} 混合后的音轨,两路都没有则返回 null
 */
function mixAudioTracks(displayStream, micStream, { micGain = 1, sysGain = 1 }) {
  const sysTrack = displayStream.getAudioTracks()[0];
  const micTrack = micStream?.getAudioTracks()[0];
  if (!sysTrack && !micTrack) return null;

  const ctx = new AudioContext();
  const dest = ctx.createMediaStreamDestination();

  // 每一路单独挂一个增益节点,方便后面做音量条和静音开关
  const connect = (track, gainValue) => {
    if (!track) return null;
    const src = ctx.createMediaStreamSource(new MediaStream([track]));
    const gain = ctx.createGain();
    gain.gain.value = gainValue;
    src.connect(gain).connect(dest);
    return gain;
  };

  const sysGainNode = connect(sysTrack, sysGain);
  const micGainNode = connect(micTrack, micGain);

  // 把上下文和增益节点挂出去,供 UI 实时调节与后续释放
  return { track: dest.stream.getAudioTracks()[0], ctx, sysGainNode, micGainNode };
}

这段代码里有个设计细节值得说:我没有把两路直接连到输出,而是各自先过一个 GainNode(增益节点)。这样做的收益很大——界面上那两个音量滑块、以及"临时静音麦克风"的按钮,只需要改 gain.value 就行,完全不用重建音频图,也不会打断正在进行的录制。

这里插一句新手提示:Web Audio 的模型是"节点连成图",信号从源节点流向目标节点,中间经过的每个节点都可以做一次变换(增益、滤波、压缩、分析等)。你可以把它想象成吉他效果器串联,插头顺序决定处理顺序。

拿到混合音轨之后,再和视频轨拼成最终要录的流:

const finalStream = new MediaStream([
  ...displayStream.getVideoTracks(),   // 画面
  ...(mixed ? [mixed.track] : []),      // 混合后的单条音轨
]);

2.5 摄像头画中画:为什么要过一层画布

想在录屏画面右下角叠一个摄像头小窗,很多人第一反应是"再加一条视频轨"。同样不行——录制出来的容器不会帮你把两条视频轨合成一个画面。

标准做法是用画布做实时合成:起一个 canvas,每一帧把屏幕画面画满整块,再把摄像头画面按比例画到指定角落,然后用 canvas.captureStream(fps) 把画布本身变成一条视频流去录。

function composePiP(screenVideoEl, camVideoEl, { fps = 30, ratio = 0.22, margin = 24 }) {
  const canvas = document.createElement('canvas');
  canvas.width = screenVideoEl.videoWidth;
  canvas.height = screenVideoEl.videoHeight;
  const ctx = canvas.getContext('2d');

  const draw = () => {
    ctx.drawImage(screenVideoEl, 0, 0, canvas.width, canvas.height);
    const w = canvas.width * ratio;
    const h = w * (camVideoEl.videoHeight / camVideoEl.videoWidth);
    ctx.drawImage(camVideoEl, canvas.width - w - margin, canvas.height - h - margin, w, h);
    rafId = requestAnimationFrame(draw);
  };
  let rafId = requestAnimationFrame(draw);

  return { stream: canvas.captureStream(fps), stop: () => cancelAnimationFrame(rafId) };
}

代价是明显的:这条路会持续占用主线程做绘制,高分辨率高帧率下 CPU 占用会上去。所以我的默认策略是——不开画中画时完全不走画布,直接用原始视频轨。只有用户勾选了摄像头才启用合成路径。这种"按需降级"的思路,在整个项目里出现了好几次。

2.6 编码与容器:文件为什么是这个大小

到这一步,我们手上有一条完整的媒体流了,接下来交给录制接口:

const mime = pickSupportedMime();
const recorder = new MediaRecorder(finalStream, {
  mimeType: mime,
  videoBitsPerSecond: 4_000_000,  // 视频码率 4 Mbps
  audioBitsPerSecond: 128_000,    // 音频码率 128 kbps
});

const chunks = [];
recorder.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); };
recorder.onstop = () => finalize(new Blob(chunks, { type: mime }));
recorder.start(1000); // 每 1000ms 吐一个数据块

几个关键点:

关于容器格式。浏览器环境下最稳的是 WebM,配 VP8/VP9 视频编码与 Opus 音频编码。对 MP4 的支持在不同内核、不同版本之间差异比较大,而且历史上出现过"改个扩展名能播、但换个播放器就废"的情况。所以正确姿势是运行时探测,而不是写死:

function pickSupportedMime() {
  const candidates = [
    'video/mp4;codecs=avc1.42E01E,mp4a.40.2',
    'video/webm;codecs=vp9,opus',
    'video/webm;codecs=vp8,opus',
    'video/webm',
  ];
  return candidates.find((t) => MediaRecorder.isTypeSupported(t)) || '';
}

关于 start(1000) 这个参数。传了时间片,录制器就会周期性地把已编码数据吐出来;不传的话,它会一直攒到停止才给你一整块。做长时间录制强烈建议传,原因有二:一是内存占用平滑,二是万一进程异常退出,已经落下来的分片还能抢救。

关于码率的估算。文件体积可以用一个很粗的式子来预判:

[

S \approx \frac{(B_v + B_a) \times t}{8 \times 1024 \times 1024}

]

其中 (S) 是文件体积(MB),(B_v) 和 (B_a) 分别是视频与音频码率(bit/s),(t) 是录制秒数。按上面 4 Mbps + 128 kbps 的配置录 10 分钟,大约是 ((4{,}000{,}000 + 128{,}000) \times 600 / 8 / 1024^2 \approx 295) MB。这个数字我建议直接显示在界面上——用户在点开始之前就知道自己大概会得到多大的文件,比录完才发现硬盘被塞满友好得多。

不同用途的码率参考值,我整理成一张表:

用途

分辨率

帧率

建议视频码率

十分钟约计

文档/代码演示

1080p

15 fps

1.5 Mbps

约 115 MB

常规操作教程

1080p

30 fps

4 Mbps

约 295 MB

含动画的界面演示

1080p

60 fps

8 Mbps

约 580 MB

高清素材留存

4K

30 fps

20 Mbps

约 1.4 GB

这里有个很多人忽略的经验:录代码和文档演示,帧率降到 15 fps 几乎看不出差别,体积却砍掉一多半。真正需要高帧率的只有那些带动画过渡的界面。

2.7 落盘:Blob 拼接与那个恼人的时长问题

录制停止后,把所有分片拼成一个 Blob,然后走保存流程。网页环境下最简单的是造一个下载链接:

function download(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = Object.assign(document.createElement('a'), { href: url, download: filename });
  a.click();
  // 延迟释放,避免部分环境下下载被中断
  setTimeout(() => URL.revokeObjectURL(url), 5000);
}

如果是桌面应用,更体面的做法是走系统的保存对话框,让用户自己选目录,并且把分片直接以追加方式写进磁盘文件,全程不在内存里堆完整视频。

然后是那个几乎所有人都会碰到的问题:录出来的 WebM 文件,进度条拖不动,时长显示成一个夸张的数字或者干脆是 Inf

原因在于流式录制时,编码器并不知道你什么时候会停,所以写文件头的时候没法填时长;标准做法本应在收尾时回写,但流式场景下这一步经常缺失。解决办法有三种,按代价从低到高:

  1. 前端修补元数据:用现成的轻量库重写时长字段,几十行代码搞定,不重新编码,速度极快。
  2. 调用命令行工具重封装:桌面应用里内置一个音视频处理程序,执行一次不重编码的"换壳"操作即可,顺手还能把 WebM 转成兼容性更好的 MP4。
  3. 播放器侧兜底:预览时先把播放位置强行拖到一个极大值再拉回来,触发播放器重新计算时长——这是个偏方,能用但不优雅。

我自己的选择是方案一做默认、方案二做可选。原因是方案二要把一个几十兆的可执行文件打包进应用,对"轻量"这个定位是个负担;但如果用户明确要 MP4,那就值得。

重封装的典型命令长这样(不重新编码,秒级完成):

# 只换容器,不动编码,速度极快
ffmpeg -i input.webm -c copy output.mkv

# 需要 MP4 且原编码不兼容时,才做一次真正的转码
ffmpeg -i input.webm -c:v libx264 -preset veryfast -crf 23 -c:a aac output.mp4

至此,整条链路就完整了。回头看会发现,真正的技术含量集中在混流元数据修补这两处,而它们恰恰是最容易在"看起来做完了"的实现里被跳过的部分。


三、把需求写成"AI 能一次做对"的规格说明

原理讲完了,接下来是方法论部分。同一个模型、同一个档位,需求写得好不好,产出质量能差出

🔒
🔒 以下内容仅对更高等级用户组开放,请升级您的账户等级以查看完整内容。
您当前:游客 · 可见 35% 内容 · 升级至 注册用户 可见 45%
👀
游客
可见 35%
✓ 当前
👤
注册用户
可见 45%
社区精英
可见 100%
🛡️
社区守护
可见 100%
仅解锁本文,永久有效。如需PDF珍藏版,请联系站长获取。 当前单篇价格 ¥5
✏️ 发表评论

请先登录后发表评论

前往登录
📊 站点统计
今日发布4 篇
文章总数106 篇
昨日发布3 篇
本月发布14 篇
建站时间37 天
🔍 搜索
📅 日历
« 2026 » « 08 »
     12
3456789
10111213141516
17181920212223
24252627282930
31      
站点公告

联系站长

微信:wyxs1638
AIGC技术社区
致力于解码 AIGC前沿技术 与经验分享
纯粹的技术交流社区

💡 欢迎您的建议与反馈,让社区变得更好

快速通道
联系站长
站长微信二维码
AI交流群
AI交流群二维码