本文由 辛梓煜@词元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。
原因在于流式录制时,编码器并不知道你什么时候会停,所以写文件头的时候没法填时长;标准做法本应在收尾时回写,但流式场景下这一步经常缺失。解决办法有三种,按代价从低到高:
- 前端修补元数据:用现成的轻量库重写时长字段,几十行代码搞定,不重新编码,速度极快。
- 调用命令行工具重封装:桌面应用里内置一个音视频处理程序,执行一次不重编码的"换壳"操作即可,顺手还能把 WebM 转成兼容性更好的 MP4。
- 播放器侧兜底:预览时先把播放位置强行拖到一个极大值再拉回来,触发播放器重新计算时长——这是个偏方,能用但不优雅。
我自己的选择是方案一做默认、方案二做可选。原因是方案二要把一个几十兆的可执行文件打包进应用,对"轻量"这个定位是个负担;但如果用户明确要 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 能一次做对"的规格说明
原理讲完了,接下来是方法论部分。同一个模型、同一个档位,需求写得好不好,产出质量能差出