FFmpeg的简单使用【Windows】--- 视频混剪+添加背景音乐

一、功能描述

点击背景音乐区域的【选择文件】按钮,选择音频文件并将其上传到服务器,上传成功后会将其存储的位置路径返回。

然后,点击要处理视频区域的【选择文件】按钮选择要进行混剪的视频素材(1-10个)。

以上两步都完成之后点击【开始处理】按钮,后台就开始选择的视频素材先上传到服务器,然后从每个素材中随机抽取 2秒 的内容进行随机混合拼接,接下来将上传的音频融合进拼接好的视频中,最后将处理好的视频输出并将其保存路径返回。

二、效果展示

处理完毕效果图 生成的8s视频 上传的4个视频素材 上传的1个音频素材

三、实现代码

说明:

前端代码是使用vue编写的。

后端接口的代码是使用nodejs进行编写的。

3.1 前端代码

html 复制代码
<template>
  <div id="app">
    <!-- 显示上传的音频 -->
    <div>
      <h2>上传的背景音乐</h2>
      <audio
        v-for="audio in uploadedaudios"
        :key="audio.src"
        :src="audio.src"
        controls
        style="width: 150px"
      ></audio>
    </div>

    <!-- 上传视频音频 -->
    <input type="file" @change="uploadaudio" accept="audio/*" />
    <hr />
    <!-- 显示上传的视频 -->
    <div>
      <h2>将要处理的视频</h2>
      <video
        v-for="video in uploadedVideos"
        :key="video.src"
        :src="video.src"
        controls
        style="width: 150px"
      ></video>
    </div>

    <!-- 上传视频按钮 -->
    <input type="file" @change="uploadVideo" multiple accept="video/*" />
    <hr />

    <!-- 显示处理后的视频 -->
    <div>
      <h2>已处理后的视频</h2>
      <video
        v-for="video in processedVideos"
        :key="video.src"
        :src="video.src"
        controls
        style="width: 150px"
      ></video>
    </div>

    <button @click="processVideos">开始处理</button>
  </div>
</template>

<script setup>
import axios from "axios";
import { ref } from "vue";

const uploadedaudios = ref([]);
const processedAudios = ref([]);
let audioIndex = 0;
const uploadaudio = async (e) => {
  const files = e.target.files;
  for (let i = 0; i < files.length; i++) {
    const file = files[i];
    const audioSrc = URL.createObjectURL(file);
    uploadedaudios.value = [{ id: audioIndex++, src: audioSrc, file }];
  }
  await processAudio();
};
// 上传音频
const processAudio = async () => {
  const formData = new FormData();
  for (const audio of uploadedaudios.value) {
    formData.append("audio", audio.file); // 使用实际的文件对象
  }
  try {
    const response = await axios.post(
      "http://localhost:3000/user/single/audio",
      formData,
      {
        headers: {
          "Content-Type": "multipart/form-data",
        },
      }
    );
    const processedVideoSrc = response.data.path;
    processedAudios.value = [
      {
        id: audioIndex++,
        // src: "http://localhost:3000/" + processedVideoSrc,
        src: processedVideoSrc,
      },
    ];
  } catch (error) {
    console.error("Error processing video:", error);
  }
};

const uploadedVideos = ref([]);
const processedVideos = ref([]);
let videoIndex = 0;

const uploadVideo = async (e) => {
  const files = e.target.files;
  for (let i = 0; i < files.length; i++) {
    const file = files[i];
    const videoSrc = URL.createObjectURL(file);
    uploadedVideos.value.push({ id: videoIndex++, src: videoSrc, file });
  }
};

const processVideos = async () => {
  const formData = new FormData();
  formData.append("audioPath", processedAudios.value[0].src);
  for (const video of uploadedVideos.value) {
    // formData.append("video", video.file); // 使用实际的文件对象
    formData.append("videos", video.file); // 使用实际的文件对象
  }
  console.log(processedAudios.value);
  for (const item of formData.entries()) {
    console.log(item);
  }
  try {
    const response = await axios.post(
      "http://localhost:3000/user/process",
      formData,
      {
        headers: {
          "Content-Type": "multipart/form-data",
        },
      }
    );
    const processedVideoSrc = response.data.path;
    processedVideos.value.push({
      id: videoIndex++,
      src: "http://localhost:3000/" + processedVideoSrc,
    });
  } catch (error) {
    console.error("Error processing video:", error);
  }
};
</script>

关于accept的说明,请查看FFmpeg的简单使用【Windows】--- 简单的视频混合拼接-CSDN博客

3.2 后端代码

routers =》users.js

javascript 复制代码
var express = require('express');
var router = express.Router();
const multer = require('multer');
const ffmpeg = require('fluent-ffmpeg');
const path = require('path');
const { spawn } = require('child_process')
// 视频
const upload = multer({
  dest: 'public/uploads/',
  storage: multer.diskStorage({
    destination: function (req, file, cb) {
      cb(null, 'public/uploads'); // 文件保存的目录
    },
    filename: function (req, file, cb) {
      // 提取原始文件的扩展名
      const ext = path.extname(file.originalname).toLowerCase(); // 获取文件扩展名,并转换为小写
      // 生成唯一文件名,并加上扩展名
      const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
      const fileName = uniqueSuffix + ext; // 新文件名
      cb(null, fileName); // 文件名
    }
  })
});
// 音频
const uploadVoice = multer({
  dest: 'public/uploadVoice/',
  storage: multer.diskStorage({
    destination: function (req, file, cb) {
      cb(null, 'public/uploadVoice'); // 文件保存的目录
    },
    filename: function (req, file, cb) {
      // 提取原始文件的扩展名
      const ext = path.extname(file.originalname).toLowerCase(); // 获取文件扩展名,并转换为小写
      // 生成唯一文件名,并加上扩展名
      const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
      const fileName = uniqueSuffix + ext; // 新文件名
      cb(null, fileName); // 文件名
    }
  })
});

const fs = require('fs');


// 处理多个视频文件上传
router.post('/process', upload.array('videos', 10), (req, res) => {
  const audioPath = path.join(path.dirname(__filename).replace('routes', 'public'), req.body.audioPath)
  const videoPaths = req.files.map(file => path.join(path.dirname(__filename).replace('routes', 'public/uploads'), file.filename));

  const outputPath = path.join('public/processed', 'merged_video.mp4');
  const concatFilePath = path.resolve('public', 'concat.txt').replace(/\\/g, '/');//绝对路径

  // 创建 processed 目录(如果不存在)
  if (!fs.existsSync("public/processed")) {
    fs.mkdirSync("public/processed");
  }


  // 计算每个视频的长度
  const videoLengths = videoPaths.map(videoPath => {
    return new Promise((resolve, reject) => {
      ffmpeg.ffprobe(videoPath, (err, metadata) => {
        if (err) {
          reject(err);
        } else {
          resolve(parseFloat(metadata.format.duration));
        }
      });
    });
  });


  // 等待所有视频长度计算完成
  Promise.all(videoLengths).then(lengths => {
    // 构建 concat.txt 文件内容
    let concatFileContent = '';
    // 定义一个函数来随机选择视频片段
    function getRandomSegment(videoPath, length) {
      const segmentLength = 2; // 每个片段的长度为2秒
      const startTime = Math.floor(Math.random() * (length - segmentLength));
      return {
        videoPath,
        startTime,
        endTime: startTime + segmentLength
      };
    }

    // 随机选择视频片段
    const segments = [];
    for (let i = 0; i < lengths.length; i++) {
      const videoPath = videoPaths[i];
      const length = lengths[i];
      const segment = getRandomSegment(videoPath, length);
      segments.push(segment);
    }

    // 打乱视频片段的顺序
    function shuffleArray(array) {
      for (let i = array.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [array[i], array[j]] = [array[j], array[i]];
      }
      return array;
    }

    shuffleArray(segments);

    // 构建 concat.txt 文件内容
    segments.forEach(segment => {
      concatFileContent += `file '${segment.videoPath.replace(/\\/g, '/')}'\n`;
      concatFileContent += `inpoint ${segment.startTime}\n`;
      concatFileContent += `outpoint ${segment.endTime}\n`;
    });

    fs.writeFileSync(concatFilePath, concatFileContent, 'utf8');

    // 获取视频总时长
    const totalVideoDuration = segments.reduce((acc, segment) => acc + (segment.endTime - segment.startTime), 0);

    // 获取音频文件的长度
    const getAudioDuration = (filePath) => {
      return new Promise((resolve, reject) => {
        const ffprobe = spawn('ffprobe', [
          '-v', 'error',
          '-show_entries', 'format=duration',
          '-of', 'default=noprint_wrappers=1:nokey=1',
          filePath
        ]);

        let duration = '';

        ffprobe.stdout.on('data', (data) => {
          duration += data.toString();
        });

        ffprobe.stderr.on('data', (data) => {
          console.error(`ffprobe stderr: ${data}`);
          reject(new Error(`Failed to get audio duration`));
        });

        ffprobe.on('close', (code) => {
          if (code !== 0) {
            reject(new Error(`FFprobe process exited with code ${code}`));
          } else {
            resolve(parseFloat(duration.trim()));
          }
        });
      });
    };
    getAudioDuration(audioPath).then(audioDuration => {
      // 计算音频循环次数
      const loopCount = Math.floor(totalVideoDuration / audioDuration);

      // 使用 ffmpeg 合并多个视频
      ffmpeg()
        .input(audioPath) // 添加音频文件作为输入
        .inputOptions([
          `-stream_loop ${loopCount}`, // 设置音频循环次数
        ])
        .input(concatFilePath)
        .inputOptions([
          '-f concat',
          '-safe 0'
        ])
        .output(outputPath)
        .outputOptions([
          '-y', // 覆盖已存在的输出文件
          '-c:v libx264', // 视频编码器
          '-preset veryfast', // 编码速度
          '-crf 23', // 视频质量控制
          '-map 0:a', // 选择第一个输入(即音频文件)的音频流
          '-map 1:v', // 选择所有输入文件的视频流(如果有)
          '-c:a aac', // 音频编码器
          '-b:a 128k', // 音频比特率
          '-t', totalVideoDuration.toFixed(2), // 设置输出文件的总时长为视频的时长
        ])
        .on('end', () => {
          const processedVideoSrc = `/processed/merged_video.mp4`;
          console.log(`Processed video saved at: ${outputPath}`);
          res.json({ message: 'Videos processed and merged successfully.', path: processedVideoSrc });
        })
        .on('error', (err) => {
          console.error(`Error processing videos: ${err}`);
          console.error('FFmpeg stderr:', err.stderr);
          res.status(500).json({ error: 'An error occurred while processing the videos.' });
        })
        .run();
    }).catch(err => {
      console.error(`Error getting audio duration: ${err}`);
      res.status(500).json({ error: 'An error occurred while processing the videos.' });
    });
  }).catch(err => {
    console.error(`Error calculating video lengths: ${err}`);
    res.status(500).json({ error: 'An error occurred while processing the videos.' });
  });

  // 写入 concat.txt 文件
  const concatFileContent = videoPaths.map(p => `file '${p.replace(/\\/g, '/')}'`).join('\n');
  fs.writeFileSync(concatFilePath, concatFileContent, 'utf8');
});

// 处理单个音频文件
router.post('/single/audio', uploadVoice.single('audio'), (req, res) => {
  const audioPath = req.file.path;
  console.log(req.file)
  res.send({
    msg: 'ok',
    path: audioPath.replace('public', '').replace(/\\/g, '/')
  })
})
module.exports = router;

注意:

关于multer配置项 和 **ffmpeg()**的说明可移步进行查看FFmpeg的简单使用【Windows】--- 视频倒叙播放-CSDN博客

3.2.1 ffprobe
1、什么是ffprobe

ffprobe是FFmpeg套件中的一个工具,用于提取媒体文件的元数据,它可以获取各种媒体文件的信息,包括视频、音频和其他媒体数据。

2、使用步骤

在JavaScript中,我们通常通过第三方库(如fluent-ffmpeg)来调用ffprobe。以下是如何使用ffluent-ffmpeg库调用ffprobe的示例:

javascript 复制代码
const ffmpeg = require('fluent-ffmpeg');

// 定义视频文件路径
const videoPath = 'path/to/video.mp4';

// 调用 ffmpeg.ffprobe
ffmpeg.ffprobe(videoPath, (err, metadata) => {
  if (err) {
    console.error('Error running ffprobe:', err);
    return;
  }

  console.log('Metadata:', metadata);
});
3、详细解释

ffmpeg.ffprobe(videoPath, callback)

⚫videoPath:视频文件的路径

⚫callback:一个回调函数,接收两个参数 err 和 metadata

🌠 err:如果执行过程中出现错误,err为错误对象,否则为null。

🌠 metadata:如果成功执行,metadata为一个包含媒体文件元数据的对象。

🌠 metadata对象包含了媒体文件的各种信息,主要包括:

🛹 format:格式级别的元数据

🛴 duration:视频的总持续时间(秒)

🛴 bit_rate:视频的比特率(bps)

🛴 size:视频文件大小(字节)

🛴 start_time:视频开始时间(秒)

🛹 streams:流级别的元数据

🛴 每个流(视频、音频等)的信息

🛴 每个流都有自己的index、codec_type、codec_name等属性

3.2.2 getAudioDuration()的说明:

⚫ 定义:getAudioDuration是一个接收filePath参数的喊出,返回一个Promise对象。

⚫ 参数:filePath表示音频文件的路径。

⚫ spawn方法:通过child_process模块创建一个子进程,运行ffprobe命令。

⚫ spawn参数:

🌠 -v error:只显示错误信息。

🌠 -show_entries format=duration:显示格式级别的duration属性。

🌠 -of default=noprint_wrappers=1:nokey=1:输出格式为纯文本,没有额外的包装。

🌠 filePath:音频文件的路径。

⚫监听stdout事件:放ffprobe子进程有数据输出时,将其转换为字符串并累加到duration变量值。

⚫监听stderr事件:当ffprobe子进程有错误输出时,打印错误信息,并拒绝Promise。

⚫监听close事件:当ffprobe子进程关闭时触发。

⚫状态码检查:

🌠 code ≠ 0:表示有错误发生,拒绝Promise并抛出错误。

🌠 code = 0:表示成功执行,解析duration变量并解析为浮点数,然后解析Promise。

相关推荐
龙之叶1 小时前
【Android Monkey源码解析四】- 异常捕获/页面控制
android·windows·adb·monkey
优选资源分享2 小时前
Advanced Renamer v4.20 便携版 批量文件重命名工具
windows·实用工具
Leo July3 小时前
【AI】AI视频生成:技术跃迁、产业落地与合规实践全解析
人工智能·音视频
大大祥5 小时前
穿山甲广告sdk接入
android·kotlin·音视频·视频播放器·广告sdk
千里马学框架6 小时前
跟着google官方文档学习车载音频Car audio configuration
学习·configuration·音视频·aaos·安卓framework开发·audio·车机
开开心心就好7 小时前
PDF密码移除工具,免费解除打印编辑复制权限
java·网络·windows·websocket·pdf·电脑·excel
souyuanzhanvip7 小时前
Dopamine v3.0.2 本地音频管理工具新版发布
音视频
线束线缆组件品替网7 小时前
Same Sky 标准化音频与电源线缆接口技术详解
人工智能·数码相机·电脑·音视频·硬件工程·材料工程
霸道流氓气质7 小时前
Java 实现折线图整点数据补全与标准化处理示例代码讲解
java·开发语言·windows
时光不弃青栀8 小时前
Windows服务器无法复制粘贴文件
运维·服务器·windows