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

发布于:2024-10-17 ⋅ 阅读:(9) ⋅ 点赞:(0)

一、功能描述

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

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

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

二、效果展示

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

三、实现代码

说明:

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

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

3.1 前端代码

<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

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的示例:

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。