视频课程创作工具最近被频繁讨论,YC S24 批次里的 Keet 把目标定得很直接:做一个能让用户针对任何话题创建视频课程的应用。这类产品的价值不在录屏本身,而在于把录制、剪辑、上传、转码、发布、播放这条链路压缩到普通讲师也能顺畅操作的程度。从工程角度看,这条链路涉及浏览器多媒体能力、对象存储、转码服务、CDN 分发和播放器兼容性,每一环都有不少容易踩坑的细节。
这篇文章不评价 Keet 的具体产品,而是从“如果要自己构建一款视频课程创作应用,技术方案该怎么设计”的角度,把录制、上传、转码、存储、播放、排错这条完整链路讲清楚。适合独立开发者、小团队技术负责人,以及刚接触音视频方向的前后端工程师参考。
1. 先拆解视频课程创作应用的核心工作流
1.1 从录制到播放,一条完整内容链路
一款课程创作 App 的输入是讲师打开摄像头、共享屏幕、说话,输出是学员在手机或浏览器里点开视频并流畅观看。中间每一步都会影响最终体验:
- 采集端要处理摄像头、麦克风、屏幕共享、课件画面叠加。
- 上传端要处理大文件、弱网、断点续传、文件校验。
- 服务端要做视频转码、切片、封面生成、字幕处理。
- 分发端要做 CDN 加速、防盗链、鉴权、多码率适配。
如果把 Keet 这类工具拆到最小闭环,至少需要四件事:录制、上传、转码、播放。后续所有章节都围绕这条主线展开。
1.2 功能模块与技术要求对照表
| 模块 | 核心职责 | 关键技术点 | 常见产出 |
|---|---|---|---|
| 录制 | 采集音视频 | getUserMedia / MediaRecorder / 屏幕捕获 | 原始视频文件,常见为 WebM、MP4 |
| 上传 | 把文件可靠传到服务端 | 分片、断点续传、校验、签名 | 存储在对象存储中的源文件 |
| 转码 | 统一编码格式并生成多码率切片 | ffmpeg、H.264、AAC、HLS | 多码率 m3u8 与 ts 切片 |
| 播放 | 在端上流畅播放 | HLS/DASH、CDN、播放器兼容 | 学员看到的可拖动视频 |
| 管理 | 课程、章节、权限、统计 | 数据库模型、权限系统、数据报表 | 课程列表、学习进度、观看统计 |
这张表后面每一节都会对应展开。理解这条链路之后,遇到视频课程类项目时,就不会只盯着“能不能录”这一个点,而是能从全链路判断瓶颈在哪里。
1.3 课程视频与普通短视频的技术差异
写代码之前先想清楚一个区别:课程视频不是短视频。短视频通常几十秒到几分钟,上传后平台统一处理;课程视频动辄十几分钟到数小时,播放场景也更复杂。这带来三个技术差异:
- 录制时长长,源文件体积大,上传不能指望一次 POST 完成。
- 内容含代码、PPT、板书等细节,编码质量要求高,码率太低会看不清文字。
- 学习行为决定了学员会反复拖动、暂停、回看,HLS 切片的关键帧间隔和 CDN 预热策略都需要专门设计。
这个对比不是要区分“谁更复杂”,而是提醒:技术方案要按课程场景的实际约束来设计。
2. 搭一个最小可运行闭环:录制、上传、转码、播放
这一节的目标是在本地环境跑通“浏览器录制 -> 上传到 Node 服务 -> ffmpeg 转码 -> 浏览器播放”的完整流程。代码以逻辑演示为主,生产环境要替换成更健壮的实现。
2.1 前置环境
| 工具 | 版本建议 | 用途 |
|---|---|---|
| Node.js | 18+ | 运行上传与转码编排接口 |
| FFmpeg | 5.x 或 6.x | 视频转码与切片 |
| 浏览器 | Chrome / Edge 最新版 | 测试 MediaRecorder 录制 |
| 本地对象存储模拟 | minio 或 s3rver | 模拟对象存储 |
注意:实际项目落地前,要先确认 Node、FFmpeg、浏览器版本满足需求。不同版本对 HLS 切片命名和 WebM 编码行为有差异。
2.2 浏览器端录制:MediaRecorder 的最小实现
浏览器录制课程画面,最简单的方式是先封装一个录制会话,内部处理getUserMedia和MediaRecorder的协作。先定义返回对象,后续可以一直持有它。
function createRecordingSession(videoElement) { let recorder = null; let stream = null; const chunks = []; return { async start() { stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 } }, audio: true }); videoElement.srcObject = stream; await videoElement.play(); recorder = new MediaRecorder(stream, { mimeType: 'video/webm;codecs=vp8,opus' }); recorder.ondataavailable = (event) => { if (event.data && event.data.size > 0) { chunks.push(event.data); } }; recorder.start(1000); }, async stop() { if (!recorder) throw new Error('not started'); recorder.stop(); await new Promise((resolve) => { recorder.onstop = () => resolve(); }); stream.getTracks().forEach((track) => track.stop()); return new Blob(chunks, { type: 'video/webm' }); } }; }这里要解释几个点:
mimeType在多数 Chrome 里是video/webm;codecs=vp8,opus,如果直接保存成 MP4 容易失败。iPhone Safari 对MediaRecorder的支持有限,移动端通常会改用原生摄像头录制或后端合流方案。recorder.start(1000)表示每秒触发一次dataavailable。这个参数影响内存占用和异常恢复粒度,太大会导致长时间录制时数据恢复成本高,太小会增加事件频率。stop()时先停止录制,再停止摄像头和麦克风轨道,避免页面右上角一直显示录音图标。
2.3 服务端上传接口:接收文件并返回标识
上传接口先用 Express 配合multer做一个可运行版本,文件落盘到uploads/目录。
const express = require('express'); const multer = require('multer'); const crypto = require('crypto'); const fs = require('fs'); const app = express(); fs.mkdirSync('uploads', { recursive: true }); const upload = multer({ storage: multer.diskStorage({ destination: 'uploads/', filename: (req, file, cb) => { const ext = file.originalname.split('.').pop() || 'webm'; cb(null, `${Date.now()}_${crypto.randomUUID()}.${ext}`); } }), limits: { fileSize: 2 * 1024 * 1024 * 1024 } }); app.post('/api/upload', upload.single('video'), (req, res) => { if (!req.file) { return res.status(400).json({ error: 'no file' }); } res.json({ videoId: req.file.filename, size: req.file.size, url: `/files/${req.file.filename}` }); }); app.use('/files', express.static('uploads')); app.listen(3000, () => console.log('server ready on 3000'));这个版本适合本地验证,但要注意:
- 直接用
originalname做路径拼接存在路径穿越风险,上面用随机 UUID 重命名可以避免。 - 2GB 的
fileSize只是单请求上限,生产环境应当使用分片上传,避免大文件在弱网下反复重传。 - 上传完成后应立即计算
md5或sha256并在响应中返回,方便后续转码任务校验文件完整性。
2.4 服务端转码:把 WebM 转成 HLS
浏览器产出的 WebM 不适合直接用于多端播放。统一转成 H.264 + AAC 的 HLS 切片,是课程类应用最常用的做法。原因有三:兼容性好、支持拖动、便于 CDN 分发。
const { execFile } = require('child_process'); const path = require('path'); const fs = require('fs'); function transcodeToHls(videoId, inputPath, outputDir) { fs.mkdirSync(outputDir, { recursive: true }); const args = [ '-i', inputPath, '-vf', 'scale=1280:720', '-pix_fmt', 'yuv420p', '-c:v', 'libx264', '-preset', 'veryfast', '-g', '60', '-sc_threshold', '0', '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', '-hls_time', '6', '-hls_playlist_type', 'vod', '-hls_segment_filename', path.join(outputDir, 'segment_%03d.ts'), path.join(outputDir, 'playlist.m3u8') ]; execFile('ffmpeg', args, (err, stdout, stderr) => { if (err) { console.error('transcode failed', stderr); return; } console.log('transcode done', videoId); }); }关键参数解释:
| 参数 | 含义 | 错误配置表现 |
|---|---|---|
-pix_fmt yuv420p | 输出标准像素格式 | 不设置可能输出 yuv444,部分播放器无法解码 |
-preset veryfast | 编码速度和压缩率的取舍 | 太慢则任务积压;太快则码率偏高 |
-g 60 | 关键帧间隔 60 帧,按 30fps 约 2 秒 | 设置过大会导致拖动卡顿、切片切点不准 |
-sc_threshold 0 | 禁用场景切换自动插入关键帧 | 不关闭会破坏固定 keyframe 间隔 |
-hls_time 6 | 每个切片约 6 秒 | 过短则切片文件多、请求频繁;过长则拖动延迟高 |
-hls_playlist_type vod | 生成 VOD 点播列表 | 不设置可能生成 event 类型,列表行为不同 |
2.5 播放端:用 HLS.js 兼容现代浏览器
原生<video>在 iOS Safari 上可以直接播放 HLS,但 Chrome、Firefox 不行。前端一般引入hls.js做兼容。
<video id="courseVideo" controls></video> <script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script> <script> const video = document.getElementById('courseVideo'); const videoId = 'your_video_id'; if (Hls.isSupported()) { const hls = new Hls(); hls.loadSource(`/stream/${videoId}/playlist.m3u8`); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () => { video.play(); }); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = `/stream/${videoId}/playlist.m3u8`; } </script>播放端最容易忽略的是 CORS。如果 HLS 切片和playlist.m3u8存放在 CDN 或对象存储,且前端页面在不同域名下,必须在响应头里配置Access-Control-Allow-Origin,否则hls.js的 XHR 请求会被浏览器拦截。
3. 决定视频质量和分发效率的关键选型
最小闭环跑通之后,问题会从“能不能跑”变成“好不好用”。视频质量、上传速度、播放清晰度、防盗链,每一个都对应一组参数和选型决策。