实战:从零构建一个支持屏幕录制与片段合并的视频管理系统 (Node.js + FFmpeg)

1. 背景 (Background)

作为开发者,我们每天都会浏览大量的技术教程(YouTube, Bilibili)。收藏夹往往乱作一团,而且很多时候,我们只需要长视频中的某几个关键片段

单纯的书签管理已经无法满足需求,我想要一个工具:

  1. 美观管理:像 Netflix 一样展示我的视频收藏。
  2. 随手记录:看到重点时,能直接录制屏幕片段。
  3. 自动归档:录制的片段自动关联到当前视频条目下,不丢失。
  4. 整理输出:能把零散的片段合并成一个完整的笔记视频。

于是,VideoHub Pro 诞生了。

C:\myApp\myvideo-manager

2. 目标 (Goal)

我们需要构建一个 B/S 架构 的全栈应用,无需繁重的数据库安装,开箱即用。

  • 前端:原生 HTML/CSS/JS(极致轻量),实现卡片式展示、剪贴板截图粘贴、Web 录屏 API 调用。
  • 后端:Node.js (Express),负责数据存储(JSON)、文件上传(Multer)以及视频处理(FFmpeg)。
  • 核心痛点解决 :解决 Web 端录屏的持久化存储,以及多段视频的后端合并下载。

3. 方法 (Method)

  • 技术栈

    • Server: Node.js + Express
    • Media Processing : fluent-ffmpeg (调用系统安装的 FFmpeg)
    • Uploads : multer
    • Frontend: HTML5, CSS3 (Grid + Glassmorphism), Native JavaScript (ES6+)
    • Database : 本地 data.json 文件 (模拟 NoSQL)
  • 数据模型设计

    一个视频对象(Video)包含基本信息和一个片段(Clips)数组:

    json 复制代码
    {
      "id": "uuid-...",
      "title": "Node.js 教程",
      "url": "https://...",
      "thumbnail": "base64...",
      "clips": [
        { "filename": "17000.webm", "createdAt": "...", "size": 1024 }
      ]
    }

4. 过程与核心代码解析 (Process)

4.1 前端交互与视觉设计

我们采用了 深色模式 (Dark Mode)毛玻璃 (Glassmorphism) 风格,利用 CSS Grid 实现响应式的卡片布局。

关键点在于粘贴上传缩略图。为了提升体验,我们监听了粘贴事件:

javascript 复制代码
// 监听粘贴事件,自动读取剪贴板中的图片流并转为 Base64
document.getElementById('paste-area').addEventListener('paste', e => {
    const items = e.clipboardData.items;
    for(let item of items) {
        if(item.type.startsWith('image')) {
            const blob = item.getAsFile();
            const reader = new FileReader();
            reader.onload = ev => {
                // 直接展示并赋值给隐藏域
                document.getElementById('v-thumb').value = ev.target.result; 
                // ...预览逻辑
            };
            reader.readAsDataURL(blob);
        }
    }
});

4.2 核心难点一:Web 录屏与自动上传

这是本项目的核心功能。传统的 Web 录屏往往只保存在浏览器内存中,刷新即丢失。我们的改进方案是:录制停止 -> 自动上传 -> 服务器持久化

前端实现 (index.html):

使用 navigator.mediaDevices.getDisplayMedia 获取屏幕流,通过 MediaRecorder 录制。

javascript 复制代码
async function startRec() {
    // 1. 唤起浏览器原生屏幕分享弹窗
    const stream = await navigator.mediaDevices.getDisplayMedia({ video:true, audio:true });
    
    // 2. 创建录制器,指定 MIME 类型
    const mediaRecorder = new MediaRecorder(stream, { mimeType: 'video/webm; codecs=vp9' });
    let chunks = []; // 临时存储二进制块

    mediaRecorder.ondataavailable = e => { if(e.data.size > 0) chunks.push(e.data); };
    
    // 3. 监听停止事件
    mediaRecorder.onstop = async () => {
        // 将 chunks 转为 Blob 对象
        const blob = new Blob(chunks, { type: 'video/webm' });
        
        // 4. 构建 FormData,准备上传
        const fd = new FormData();
        fd.append('clip', blob, `rec_${Date.now()}.webm`);
        fd.append('videoId', currentVideoId); // 关键:带上当前视频的 ID

        // 5. 立即上传服务器
        await fetch(`${API}/upload-clip`, { method: 'POST', body: fd });
        
        // 停止流,释放资源
        stream.getTracks().forEach(t => t.stop());
        refreshClipsPanel(); // 刷新 UI
    };

    mediaRecorder.start();
}

后端处理 (server.js):

后端接收文件,保存到 uploads 目录,并更新 data.json 中的对应记录。

javascript 复制代码
app.post('/api/upload-clip', upload.single('clip'), (req, res) => {
    const { videoId } = req.body;
    // ...读取 JSON 数据
    const idx = videos.findIndex(v => v.id === videoId);
    
    if (idx !== -1) {
        // 构建片段元数据对象
        const clipInfo = {
            filename: req.file.filename, // Multer 生成的物理文件名
            originalName: req.file.originalname,
            size: req.file.size,
            createdAt: new Date()
        };
        // 关联数据:将片段信息 push 到该视频的 clips 数组中
        videos[idx].clips.push(clipInfo);
        writeDB(videos); // 写入磁盘
        res.json({ success: true });
    }
});

分析:这种"ID关联法"使得录屏文件不再是孤立的,而是永远从属于某个具体的视频条目。


4.3 核心难点二:多片段合并 (FFmpeg)

当用户录制了片段 A、片段 B、片段 C 后,可能只想把 A 和 C 合并下载。

策略变化

早期版本是前端把 Blob 传给后端合并,这很浪费流量。现在的策略是:文件已经在服务器上了,前端只需要传"文件名列表",后端在本地进行合并

前端逻辑

用户勾选复选框,获取文件名数组:

javascript 复制代码
const cbs = document.querySelectorAll('.clip-cb:checked');
const filenames = Array.from(cbs).map(cb => cb.value); // ['file1.webm', 'file3.webm']

await fetch(`${API}/merge-files`, {
    body: JSON.stringify({ filenames }) // 只发文件名,极快
});

后端逻辑 (server.js):

使用 fluent-ffmpeg 链式调用。

javascript 复制代码
app.post('/api/merge-files', (req, res) => {
    const { filenames } = req.body;
    const outputFilename = `merged-${Date.now()}.mp4`;
    const outputPath = path.join(UPLOAD_DIR, outputFilename);
    
    const command = ffmpeg();

    // 1. 遍历添加输入源 (Input)
    filenames.forEach(name => {
        // 拼接服务器的绝对路径
        command.input(path.join(UPLOAD_DIR, name));
    });

    // 2. 执行合并 (Merge)
    command
        .on('error', (err) => res.status(500).send('Merge failed'))
        .on('end', () => {
            // 3. 返回合并后的文件 URL
            res.json({ url: `/uploads/${outputFilename}` });
        })
        .mergeToFile(outputPath, UPLOAD_DIR); // 输出
});

分析:这个设计将繁重的视频处理工作全部交给了服务器(Node.js 调用底层 FFmpeg),前端只需要发送轻量级的指令,体验非常流畅。


5. 结果展示 (Result)

最终,我们得到了一个功能闭环的系统:

  1. 卡片墙:首页展示所有视频,缩略图清晰,支持分类过滤。
  2. 播放详情页
    • 左侧:智能解析 URL(YouTube/Bilibili 自动嵌入 iframe),下方有"录制"按钮。
    • 右侧:显示所有关联的录屏片段历史。
  3. 工作流
    • 点击"录制" -> 选择区域 -> 录制结束 -> 自动出现在右侧列表
    • 在右侧列表中勾选第1条和第3条 -> 点击"合并下载" -> 浏览器弹出合并后的 MP4 下载。
相关推荐
Gogo112118 分钟前
构建高性能 Node.js 集中式日志体系 (下篇):Pino + PM2 + OpenSearch 代码落地实战
node.js
小岛前端25 分钟前
Node.js 宣布重大调整,运行十年的规则要改了!
前端·node.js
前端付豪1 小时前
Nest 项目小实践之前端注册登陆
前端·node.js·nestjs
codingWhat19 小时前
整理「祖传」代码,就是在开发脚手架?
前端·javascript·node.js
ServBay19 小时前
Node.js、Bun 与 Deno,2026 年后端运行时选择指南
node.js·deno·bun
码路飞1 天前
Node.js 中间层我维护了两年,这周终于摊牌了——成本账单算完我人傻了
node.js
None3212 天前
【NestJs】使用Winston+ELK分布式链路追踪日志采集
javascript·node.js
Dilettante2582 天前
这一招让 Node 后端服务启动速度提升 75%!
typescript·node.js
Mr_li3 天前
NestJS 集成 TypeORM 的最优解
node.js·nestjs
UIUV3 天前
node:child_process spawn 模块学习笔记
javascript·后端·node.js