【视频混剪Demo】FFmpeg的使用【Windows】

embedded/2024/10/30 22:21:30/

#1024程序员节 | 征文# 

目录

一、简介

二、音频素材页

2.1 功能描述

👉 搜索

👉 添加

👉 删除

2.2 效果展示

2.3 代码实现 

👉 前端

👉 后端

三、视频素材页

3.1 功能描述

👉 搜索

👉 添加

👉 编辑

👉 删除

3.2 效果展示

3.3 代码实现

👉 前端

👉 后端

四、分组管理页

4.1 功能描述

👉 搜索

👉 添加

👉 编辑

👉 删除

4.2 效果展示

4.3 代码实现

👉 前端

👉 后端

五、视频混剪

5.1 功能描述

👉 搜索

👉 添加

👉 删除

5.2 效果展示

5.3 代码实现

👉 前端

👉 后端


一、简介

此篇文章带来的是 使用 FFmpeg 实现视频混剪并且可以指定生成视频的数量以及生成视频的时长,此案例也是对前边几篇文章功能的综合。

说明:

此案例中的代码部分,前端使用的是 Vue + ElementPlus + Vite、后端使用的是 Nodejs + FFmpeg + MySQL

👇下方提供的代码为每一部分的核心代码,如果需要案例的完整代码,可以在评论区留言我来私信你!!!👆

二、音频素材页

2.1 功能描述

👉 搜索

在搜索栏中输入 音频名称、上传时间 后点击搜索按钮即可,也可以只输入一个条件搜索。

👉 添加

点击页面上的【添加音频】按钮,然后在弹出的上传音频对话框中输入 音频名称,并选择要上传的音频文件之后直接点击确定按钮即可完成音频文件的上传。

👉 删除

点击表格中的【删除】按钮即可删除对应的音频文件

2.2 效果展示

音频素材页面
点击上一张图片中的添加音频按钮弹出的对话框
选择要上传的音频文件

2.3 代码实现 

👉 前端

<template><div><el-button type="primary" @click="dialogVisible = true">添加音频</el-button><el-row style="margin: 10px 0"><el-form v-model="queryParams" inline><el-form-item label="音频名称"><el-inputv-model="queryParams.name"placeholder="输入要查询的文件名称"style="width: 200px"></el-input></el-form-item><el-form-item label="上传时间"><el-date-pickerv-model="daterange"type="daterange"unlink-panelsrange-separator="至"start-placeholder="开始日期"end-placeholder="结束日期":shortcuts="shortcuts"/></el-form-item></el-form><div style="margin-left: 20px"><el-button type="primary" @click="handleSearch">搜索</el-button><el-button type="info" plain @click="handleReset">重置</el-button></div></el-row><el-table:data="tableData"stripeborder:style="{ height: tableHeight ? tableHeight + 'px' : '' }"><el-table-column prop="id" label="ID" align="center" width="80" /><el-table-column prop="name" label="音频名称" align="center" /><el-table-columnprop="path"label="音频文件"align="center"min-width="260"><template #default="scope"><audio :src="scope.row.path" controls class="audioStyle"></audio></template></el-table-column><el-table-columnprop="type"label="音频类型"align="center"width="100"/><el-table-column prop="size" label="文件大小" align="center" width="90"><template #default="scope"> {{ scope.row.size }}M </template></el-table-column><el-table-columnprop="upload_time"label="上传时间"align="center"width="200"/><el-table-column label="操作" align="center" width="90" fixed="right"><template #default="scope"><el-buttonv-if="choose"type="success"size="small"@click="handleChoose(scope.row)">选择</el-button><el-buttonv-elsetype="danger"size="small"@click="handleDelete(scope.row)">删除</el-button></template></el-table-column></el-table><el-row style="margin-top: 10px; justify-content: end; align-items: center"><span style="margin-right: 20px; color: #606266">共 {{ total }} 条</span><el-paginationbackgroundlayout="prev, pager, next":page-size="queryParams.size":total="total"@current-change="handleCurrentChange"></el-pagination></el-row><!-- 添加音频对话框 --><el-dialog v-model="dialogVisible" title="上传音频"><el-form :model="formData" :rules="rules" ref="ruleFormRef"><el-form-item label="音频名称" prop="name"><el-inputv-model="formData.name"placeholder="请输入音频名称"></el-input></el-form-item><el-form-item label="音频文件" prop="file"><el-inputv-model="formData.path"placeholder="请选择音频文件"class="input-with-select"disabled><template #append><el-uploadv-model:file-list="fileList"action="":limit="1":before-upload="beforeUpload":show-file-list="false"accept="audio/*"><el-button :icon="Upload" class="upload-btn">上传</el-button></el-upload></template></el-input></el-form-item></el-form><template #footer><div class="dialog-footer"><el-button @click="handleCancel">取消</el-button><el-button type="primary" @click="handleSubmit"> 确定 </el-button></div></template></el-dialog></div>
</template><script setup>
import { onMounted, ref } from "vue";
import { Upload } from "@element-plus/icons-vue";
import { post, get, del } from "@/utils/http";
import { useStore } from "@/store";const porp = defineProps(["choose"]);
const store = useStore();const tableData = ref([]);
const total = ref(0);
// 查询条件
const queryParams = ref({name: undefined,startTime: undefined,endTime: undefined,page: 1,size: 10,
});
const daterange = ref([]);
const shortcuts = [{text: "最近一周",value: () => {const end = new Date();const start = new Date();start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);return [start, end];},},{text: "最近一个月",value: () => {const end = new Date();const start = new Date();start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);return [start, end];},},{text: "最近三个月",value: () => {const end = new Date();const start = new Date();start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);return [start, end];},},
];
// 获取音频列表
const getList = () => {get("/audio/list", queryParams.value).then((res) => {tableData.value = res.data;total.value = res.total;});
};
// 搜索
const handleSearch = () => {queryParams.value.startTime = daterange.value[0];queryParams.value.endTime = daterange.value[1];console.log(queryParams.value);getList();
};
// 重置
const handleReset = () => {queryParams.value = {name: undefined,startTime: undefined,endTime: undefined,page: 1,size: 10,};daterange.value = [];getList();
};
// 切换页
const handleCurrentChange = (page) => {queryParams.value.page = page;getList();
};const dialogVisible = ref(false); //弹出对话框是否显示
const fileList = ref([]);
const ruleFormRef = ref(null);
const formData = ref({name: "",path: "",file: "",
});
// 校验规则
const rules = {name: [{ required: true, message: "请输入音频名称", trigger: "blur" }],file: [{ required: true, message: "请选择要上传的音频文件", trigger: "blur" },],
};
// 点击取消按钮
const handleCancel = () => {dialogVisible.value = false;// 重置表单formData.value = {name: "",file: "",};
};
// 点击确定按钮
const handleSubmit = () => {const data = new FormData();data.append("name", formData.value.name);data.append("audio", formData.value.file);console.log(formData.value);// 先进行表单的验证ruleFormRef.value.validate((valid) => {if (valid) {post("/audio/single/audio", data).then((res) => {ElMessage.success("上传成功");console.log("上传成功", res);dialogVisible.value = false;getList();});}});
};// 上传文件之前
const beforeUpload = (file) => {formData.value.path = URL.createObjectURL(file);formData.value.file = file;return false;
};// 删除
const handleDelete = (item) => {del(`/audio/delete/${item.id}`).then((res) => {ElMessage.success("删除成功");getList();});
};
// 选择音频
const handleChoose = (item) => {store.chooseBgm(item);store.setAudioDialog();
};
onMounted(() => {getList();
});
</script><style lang="scss" scoped>
.el-table {margin-top: 10px;
}
.el-button.upload-btn {height: 100%;background: #f56c6c;color: #fff;&:hover {background: #f89898;color: #fff;}
}
.el-input-group__append > div {display: flex;
}
.audioStyle {width: 250px;height: 40px;
}
</style>

👉 后端

var express = require('express');
var router = express.Router();
const multer = require('multer');
const path = require('path');
const connection = require('../config/db.config')
const baseURL = 'http://localhost:3000'// 音频
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); // 文件名}})
});// 上传单个音频文件
router.post('/single/audio', uploadVoice.single('audio'), (req, res) => {const audioPath = req.file.path.replace('public', '').replace(/\\/g, '/');const { name } = req.bodyconst type = req.file.filename.split('.')[1]const size = (req.file.size / (1024 * 1024)).toFixed(2)console.log(req.file)console.log(name, audioPath, type, size)const insertSql = 'insert into audio (name,path,type,size) values (?,?,?,?)'const insertValues = [name, audioPath, type, size]connection.query(insertSql, insertValues, (err, results) => {if (err) {console.error('插入数据失败:', err.message);res.send({status: 500,msg: err.message})return;}console.log('音频插入成功');res.send({status: 200,msg: 'ok',path: audioPath// 返回新插入记录的ID})})
})// 获取音频列表
router.get('/list', (req, res) => {let { name, startTime, endTime, page, size, isAll } = req.querystartTime = startTime ? (new Date(startTime)).toLocaleDateString() : ''endTime = endTime ? (new Date(endTime)).toLocaleDateString() : ''console.log({ name, startTime, endTime, page, size })let selectSql = ''// 按照名称查询并按照id进行倒序排列if (name && !startTime) {selectSql = `select * from audio where name like '%${name}%' order by id desc`} else if (!name && startTime) {// 按照上传时间并按照id进行倒序排列selectSql = `select * from audio where Date(upload_time) between '${startTime}' and '${endTime}' order by id desc`} else if (name && startTime) {// 按照时间时间和名称并按照id进行倒序排列selectSql = `select * from audio where name like '%${name}%' and Date(upload_time) between '${startTime}' and '${endTime}' order by id desc`} else {selectSql = 'select * from audio order by id desc'}connection.query(selectSql, (err, results) => {if (err) {console.error('查询数据失败:', err.message);res.send({status: 500,msg: err.message})return;}const data = results.map(item => {item.path = baseURL + item.pathitem['upload_time'] = item['upload_time'].toLocaleString()return item})res.send({status: 200,msg: 'ok',data: isAll ? data : data.slice((page - 1) * size, page * size),total: data.length})})
})// 删除音频
router.delete('/delete/:id', (req, res) => {const { id } = req.paramsconst deleteSql = `delete from audio where id = ?`connection.query(deleteSql, [id], (err, results) => {if (err) {console.error('删除数据失败:', err.message);res.send({status: 500,msg: err.message})return;}console.log('音频删除成功')res.send({status: 200,msg: 'ok',})})
})module.exports = router;

三、视频素材页

3.1 功能描述

👉 搜索

在搜索栏中输入 视频名称、视频分组、上传时间 后点击搜索按钮即可,也可以只输入一个或者两个条件搜索。

👉 添加

点击页面上的【上传视频】按钮,然后在弹出的上传视频对话框中输入 视频名称、选择 视频分组(选填),并选择要上传的视频文件之后,点击确定按钮即可完成视频文件的上传。

👉 编辑

点击页面中的【编辑】按钮,即可在弹出的对话框中编辑该条视频的 名称和分组

👉 删除

点击页面中的【删除】按钮,即可删除对应的视频文件。

3.2 效果展示

视频素材页面
点击上一张图片中的上传视频按钮弹出的对话框
选择要上传的视频文件

3.3 代码实现

👉 前端

<template><div><el-button type="primary" @click="handleAdd">上传视频</el-button><el-row style="margin-top: 10px"><el-form v-model="queryParams" inline><el-form-item label="视频名称"><el-inputv-model="queryParams.name"placeholder="输入要查询的文件名称"style="width: 200px"></el-input></el-form-item><el-form-item label="视频分组"><el-selectv-model="queryParams.group_id"clearableplaceholder="请选择分组"style="width: 200px"><el-optionv-for="item in groups":key="item.id":label="item.group_name":value="item.id"/></el-select></el-form-item><el-form-item label="上传时间"><el-date-pickerv-model="daterange"type="daterange"unlink-panelsrange-separator="至"start-placeholder="开始日期"end-placeholder="结束日期":shortcuts="shortcuts"/></el-form-item></el-form><div style="margin-left: 20px"><el-button type="primary" @click="handleSearch">搜索</el-button><el-button type="info" plain @click="handleReset">重置</el-button></div></el-row><el-button type="success" @click="handleChooseMul" v-if="choose">选择</el-button><el-tableref="tableRef":data="tableData"stripeborder@selection-change="handleSelectionChange":style="{ height: tableHeight ? tableHeight + 'px' : '' }"><el-table-column type="selection" width="55" /><el-table-column prop="id" label="ID" align="center" width="80" /><el-table-column prop="name" label="视频名称" align="center" /><el-table-column prop="name" label="所属组" align="center"><template #default="scope"><el-tag type="success" v-if="scope.row.group_id">{{scope.row.group_name}}</el-tag><el-tag type="info" v-else>暂未分组</el-tag></template></el-table-column><el-table-columnprop="path"label="视频文件"align="center"min-width="320"><template #default="scope"><video :src="scope.row.path" controls class="videoStyle"></video></template></el-table-column><el-table-column prop="type" label="视频类型" align="center" /><el-table-column prop="size" label="文件大小" align="center"><template #default="scope"> {{ scope.row.size }}M </template></el-table-column><el-table-columnprop="upload_time"label="上传时间"align="center"width="200"/><el-table-column label="操作" align="center" width="130" fixed="right"><template #default="scope"><el-buttonv-if="choose"type="success"@click="handleChooseSingle(scope.row)">选择</el-button><div style="display: flex; justify-content: space-between" v-else><el-buttontype="success"size="small"@click="handleEdit(scope.row)">编辑</el-button><el-buttontype="danger"size="small"@click="handleDelete(scope.row)">删除</el-button></div></template></el-table-column></el-table><el-row style="margin-top: 10px; justify-content: end; align-items: center"><span style="margin-right: 20px; color: #606266">共 {{ total }} 条</span><el-paginationbackgroundlayout="prev, pager, next":page-size="queryParams.size":total="total"@current-change="handleCurrentChange"></el-pagination></el-row><!-- 上传视频对话框 --><el-dialog v-model="dialogVisible" :title="title"><el-form:model="formData":rules="rules"ref="ruleFormRef"label-width="80"><el-form-item label="视频名称" prop="name"><el-inputv-model="formData.name"placeholder="请输入视频名称"></el-input></el-form-item><el-form-item label="视频分组" prop="group_id"><el-selectv-model="formData.group_id"clearableplaceholder="请选择分组"style="width: 200px"><el-optionv-for="item in groups":key="item.id":label="item.group_name":value="item.id"/></el-select></el-form-item><el-form-item label="视频文件" prop="file"><el-inputv-model="formData.path"placeholder="请选择视频文件"class="input-with-select"disabled><template #append><el-uploadv-model:file-list="fileList"action="":limit="1":before-upload="beforeUpload":show-file-list="false"accept="video/*"><el-button:icon="Upload"class="upload-btn":disabled="isDisabled">上传</el-button></el-upload></template></el-input></el-form-item></el-form><template #footer><div class="dialog-footer"><el-button @click="handleCancel">取消</el-button><el-button type="primary" @click="handleSubmit"> 确定 </el-button></div></template></el-dialog></div>
</template><script setup>
import { onMounted, ref, watch, watchEffect } from "vue";
import { Upload } from "@element-plus/icons-vue";
import { post, get, del, put } from "@/utils/http";
import { useStore } from "@/store";const store = useStore();
const props = defineProps(["choose", "tableHeight"]);
const tableRef = ref(null);
const tableData = ref([]);
const total = ref(0);
// 查询条件
const queryParams = ref({name: undefined,startTime: undefined,endTime: undefined,page: 1,size: 10,group_id: "",
});
const daterange = ref([]);
const shortcuts = [{text: "最近一周",value: () => {const end = new Date();const start = new Date();start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);return [start, end];},},{text: "最近一个月",value: () => {const end = new Date();const start = new Date();start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);return [start, end];},},{text: "最近三个月",value: () => {const end = new Date();const start = new Date();start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);return [start, end];},},
];
// 获取视频列表
const getList = () => {get("/video/list", queryParams.value).then((res) => {tableData.value = res.data;total.value = res.total;});
};
const groups = ref([]);
// 获取分组列表
const getGroup = () => {get("/group/list", { isAll: true }).then((res) => {groups.value = res.data;});
};
// 搜索
const handleSearch = () => {queryParams.value.startTime = daterange.value[0];queryParams.value.endTime = daterange.value[1];getList();
};
// 重置
const handleReset = () => {queryParams.value = {name: undefined,startTime: undefined,endTime: undefined,page: 1,size: 10,};daterange.value = [];getList();
};
// 切换页
const handleCurrentChange = (page) => {queryParams.value.page = page;getList();
};const dialogVisible = ref(false); //弹出对话框是否显示
const fileList = ref([]);
const ruleFormRef = ref(null);
const title = ref("上传视频");
const formData = ref({name: undefined,path: undefined,file: undefined,group_id: "",id: undefined,
});
// 校验规则
const rules = {name: [{ required: true, message: "请输入视频名称", trigger: "blur" }],file: [{ required: true, message: "请选择要上传的视频文件", trigger: "blur" },],group_id: [{ required: false, message: "请选择分组", trigger: "blur" }],
};
const isDisabled = ref(false);
// 上传视频按钮
const handleAdd = () => {isDisabled.value = false;dialogVisible.value = true;title.value = "上传视频";
};
const currentItem = ref({});
// 编辑
const handleEdit = (item) => {// 弹出对话框的标题title.value = "编辑视频";isDisabled.value = true; // 编辑的时候上传文件的按钮是不可用的,因为上传文件的时候会覆盖掉原来的文件dialogVisible.value = true; //编辑的时候显示对话框// 点击编辑的时候将当前点击的行赋值给currentItemformData.value = { ...item, file: item.path };currentItem.value = item;
};
// 点击取消按钮
const handleCancel = () => {dialogVisible.value = false;// 重置表单formData.value = {name: undefined,path: undefined,file: undefined,group_id: "",id: undefined,};// 重置表单校验ruleFormRef.value.resetFields();
};// 点击确定按钮
const handleSubmit = () => {const data = new FormData();data.append("name", formData.value.name);data.append("group_id", formData.value.group_id);// 先进行表单的验证ruleFormRef.value.validate((valid) => {if (title.value == "上传视频") {data.append("video", formData.value.file);}if (valid) {if (title.value == "上传视频") {post("/video/add", data).then((res) => {ElMessage.success("上传成功");dialogVisible.value = false;getList();});} else {const params = {id: formData.value.id,name: formData.value.name,group_id: formData.value.group_id || null,preGroupId: currentItem.value.group_id,};console.log("传递的参数", params);put("/video/edit", params).then((res) => {ElMessage.success("修改成功");dialogVisible.value = false;getList();});}}});
};// 上传文件之前
const beforeUpload = (file) => {formData.value.path = URL.createObjectURL(file);formData.value.file = file;return false;
};
// 删除
const handleDelete = (item) => {del(`/video/delete`, { id: item.id, group_id: item.group_id }).then((res) => {ElMessage.success("删除成功");getList();});
};// 选择单个文件
const handleChooseSingle = (item) => {store.setVideoList(item);store.setAudioDialog(false);tableRef.value.clearSelection();
};
const videoList = ref([]);
// 切换选中的数据行
const handleSelectionChange = (e) => {videoList.value = e;
};
// 选择多个文件
const handleChooseMul = () => {store.setAudioDialog(false);videoList.value.forEach((item) => {store.setVideoList(item);});videoList.value = [];tableRef.value.clearSelection();
};watch(() => store.isSelect,(newVal, oldVal) => {console.log("isSelect changed from", oldVal, "to", newVal);if (!newVal) {tableRef.value?.clearSelection();}}
);onMounted(() => {getList();getGroup();
});
</script><style lang="scss" scoped>
.el-table {margin-top: 10px;
}
.el-button.upload-btn {height: 100%;background: #f56c6c;color: #fff;&:hover {background: #f89898;color: #fff;}
}
.el-input-group__append > div {display: flex;
}
.videoStyle {width: 281px;height: 157px;
}
</style>

👉 后端

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 connection = require('../config/db.config')
const baseURL = 'http://localhost:3000'
const async = require('async')
// 视频
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 fs = require('fs');
// 上传单个视频
router.post('/add', upload.single('video'), (req, res) => {const videoPath = req.file.path.replace('public', '').replace(/\\/g, '/');const { name, group_id } = req.bodyconst type = req.file.filename.split('.')[1]const size = (req.file.size / (1024 * 1024)).toFixed(2)console.log({ videoPath, name, group_id, type, size })let insertSql = ''const updateSql = `update block set num=num+1 where id=?`if (group_id) {insertSql = 'insert into video (name,path,type,size,group_id) values (?,?,?,?,?)'} else {insertSql = 'insert into video (name,path,type,size) values (?,?,?,?)'}const insertValues = [name, videoPath, type, size, group_id]// 开启事务connection.beginTransaction((err) => {if (err) {console.error('开始事务失败:', err.message);res.send({ status: 500, msg: err.message });return;}// 先向数据库插入数据,再更新分组表connection.query(insertSql, insertValues, (err, results) => {if (err) {// 失败回滚rollbackAndRespond(err);return;}// 如果上传视频的时候有分组,则更新分组表if (group_id) {connection.query(updateSql, [group_id], (err, results) => {if (err) {// 失败回滚rollbackAndRespond(err);return;}// 提交事务commitTransaction();});} else {// 提交事务commitTransaction();}});// 提交事务const commitTransaction = () => {connection.commit((err) => {if (err) {// 失败回滚rollbackAndRespond(err);return;}res.send({ status: 200, msg: 'ok', data: videoPath });});};});
});// 获取视频列表
router.get('/list', (req, res) => {let { group_id, name, startTime, endTime, page, size } = req.querystartTime = startTime ? (new Date(startTime)).toLocaleDateString() : ''endTime = endTime ? (new Date(endTime)).toLocaleDateString() : ''console.log("视频列表", { group_id, name, startTime, endTime, page, size })let conditions = [];if (name) {conditions.push(`v.name like '%${name}%'`);}if (startTime && endTime) {conditions.push(`Date(v.upload_time) between '${startTime}' and '${endTime}'`);}if (group_id) {conditions.push(`v.group_id=${group_id}`);}const conditionString = conditions.length > 0 ? `where ${conditions.join(' and ')}` : '';const selectSql = `select v.*, b.group_name from video v left join block b on v.group_id = b.id ${conditionString} order by v.id desc
`;connection.query(selectSql, (err, results) => {if (err) {console.error('查询数据失败:', err.message);res.send({status: 500,msg: err.message})return;}const data = results.map(item => {item.path = baseURL + item.pathitem['upload_time'] = item['upload_time'].toLocaleString()return item})res.send({status: 200,msg: 'ok',data: data.slice((page - 1) * size, page * size),total: results.length})})
})// 删除视频
router.delete('/delete', (req, res) => {const { id, group_id } = req.bodyconst deleteSql = `delete from video where id=?`const decreaseBlockSql = `update block set num=num-1 where id=?`connection.beginTransaction(err => {if (err) {rollbackAndRespond(err)return}connection.query(decreaseBlockSql, [group_id], (err) => {if (err) {rollbackAndRespond(err)}connection.query(deleteSql, [id], (err) => {if (err) {rollbackAndRespond(err)} else {connection.commit(err => {if (err) {rollbackAndRespond(err)} else {res.send({status: 200,msg: '删除成功'})}})}})})})
})
// 编辑视频
router.put('/edit', (req, res) => {const { id, name, group_id, preGroupId } = req.bodyconsole.log('编辑视频', req.body)const upvideodateSql = `update video set name = ?,group_id = ? where id = ?`// 修改分组,如果原分组不为空,则要减少原分组中的视频数量const decreaseBlockSql = `update block set num = num-1 where id = ?`// 修改分组,如果新分组不为空,则要增加新分组中的视频数量const increaseBlockSql = `update block set num=num+1 where id = ?`connection.beginTransaction(err => {if (err) {console.error('开始事务失败:', err.message);rollbackAndRespond(err)return}connection.query(upvideodateSql, [name, group_id, id], (err) => {if (err) {rollbackAndRespond(err)return;}// 任务列表const tasks = []// 如果原分组不为空,则要减少原分组中的视频数量if (preGroupId) {tasks.push((callback) => {connection.query(decreaseBlockSql, [preGroupId], (err) => {if (err) {callback(err)} else {callback(null)}})})}// 如果新分组不为空,则要增加新分组中的视频数量if (group_id) {tasks.push(callback => {connection.query(increaseBlockSql, [group_id], (err) => {if (err) {callback(err)} else {callback(null)}})})}async.series(tasks, (err) => {if (err) {rollbackAndRespond(err)return}connection.commit(err => {if (err) {rollbackAndRespond(err)return}res.send({status: 200,msg: 'ok'})})})})})
})// 处理多个视频文件上传
router.post('/process', upload.array('videos', 10), (req, res) => {// 要添加的背景音频const audioPath = path.join(path.dirname(__filename).replace('routes', 'public'), req.body.audioPath)//要生成多长时间的视频const { timer } = req.body// 格式化上传的音频文件的路径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 => {console.log('lengths', lengths)// 构建 concat.txt 文件内容let concatFileContent = '';// 定义一个函数来随机选择视频片段function getRandomSegment(videoPath, length, segmentLength) {// 如果该素材的长度小于截取的长度,则直接返回整个视频素材if (segmentLength >= length) {return {videoPath,startTime: 0,endTime: length};}const startTime = Math.floor(Math.random() * (length - segmentLength));return {videoPath,startTime,endTime: startTime + segmentLength};}// 随机选择视频片段const segments = [];let totalLength = 0;// 初始分配for (let i = 0; i < lengths.length; i++) {const videoPath = videoPaths[i];const length = lengths[i];const segmentLength = Math.min(timer / lengths.length, length);const segment = getRandomSegment(videoPath, length, segmentLength);segments.push(segment);totalLength += (segment.endTime - segment.startTime);}console.log("初始化分配之后的视频长度", totalLength)/* 这段代码的主要作用是在初始分配后,如果总长度 totalLength 小于目标长度 targetLength,则通过不断从剩余的视频素材中随机选择片段来填补剩余的时间,直到总长度达到目标长度为止。每次循环都会计算剩余需要填补的时间,并从随机选择的视频素材中截取一段合适的长度。*/// 如果总长度小于目标长度,则从剩余素材中继续选取随机片段while (totalLength < timer) {// 计算还需要多少时间才能达到目标长度const remainingTime = timer - totalLength;// 从素材路径数组中随机选择一个视频素材的索引const videoIndex = Math.floor(Math.random() * videoPaths.length);// 根据随机选择的索引,获取对应的视频路径和长度const videoPath = videoPaths[videoIndex];const length = lengths[videoIndex];// 确定本次需要截取的长度// 这个长度不能超过剩余需要填补的时间,也不能超过素材本身的长度,因此选取两者之中的最小值const segmentLength = Math.min(remainingTime, length);// 生成新的视频片段const segment = getRandomSegment(videoPath, length, segmentLength);// 将新生成的视频片段对象添加到片段数组中segments.push(segment);// 更新总长度totalLength += (segment.endTime - segment.startTime);}// 打乱视频片段的顺序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);console.log("最终要输出的视频总长度为", totalVideoDuration)// 获取音频文件的长度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');
});// 事务回滚
const rollbackAndRespond = (error) => {connection.rollback(() => {console.error('事务回滚:', error.message);res.send({ status: 500, msg: error.message });});
};module.exports = router;

四、分组管理页

4.1 功能描述

👉 搜索

在搜索栏中输入 分组名称、创建时间 后点击搜索按钮即可,也可以只输入一个条件搜索。

👉 添加

点击页面上的【添加分组】按钮,然后在弹出的上传视频对话框中输入分组名称点击确定按钮即可完成视频分组的创建。

👉 编辑

点击页面中的【编辑】按钮,即可在弹出的对话框中编辑该分组的名称。

👉 删除

点击页面中的【删除】按钮,即可删除对应的分组。

说明:

当要删除的分组下有关联的视频时,是不允许删除的;只有当该分组关联的视频数量为0时才允许将其删除。

4.2 效果展示

视频分组管理页面
点击上一张图片中的添加分组按钮弹出的对话框

4.3 代码实现

👉 前端

<template><div><el-button type="primary" @click="handleAdd">添加分组</el-button><el-row style="margin: 20px 0"><el-form v-model="queryParams" inline><el-form-item label="分组名称"><el-inputv-model="queryParams.group_name"placeholder="输入要查询的文件名称"style="width: 200px"></el-input></el-form-item><el-form-item label="创建时间"><el-date-pickerv-model="daterange"type="daterange"unlink-panelsrange-separator="至"start-placeholder="开始日期"end-placeholder="结束日期":shortcuts="shortcuts"/></el-form-item></el-form><div style="margin-left: 20px"><el-button type="primary" @click="handleSearch">搜索</el-button><el-button type="info" plain @click="handleReset">重置</el-button></div></el-row><el-table :data="tableData" stripe border><el-table-column prop="id" label="ID" align="center" width="80" /><el-table-column prop="group_name" label="分组名称" align="center" /><el-table-column prop="num" label="关联视频数量" align="center" /><el-table-columnprop="create_time"label="创建时间"align="center"width="200"/><el-table-column label="操作" align="center" width="130" fixed="right"><template #default="scope"><div style="display: flex; justify-content: space-between"><el-buttontype="success"size="small"@click="handleEdit(scope.row)">编辑</el-button><el-buttontype="danger"size="small"@click="handleDelete(scope.row)">删除</el-button></div></template></el-table-column></el-table><el-row style="margin-top: 20px; justify-content: end; align-items: center"><span style="margin-right: 20px; color: #606266">共 {{ total }} 条</span><el-paginationbackgroundlayout="prev, pager, next":page-size="queryParams.size":total="total"@current-change="handleCurrentChange"></el-pagination></el-row><!-- 添加分组对话框 --><el-dialog v-model="dialogVisible" :title="title"><el-form :model="formData" :rules="rules" ref="ruleFormRef"><el-form-item label="分组名称" prop="group_name"><el-inputv-model="formData.group_name"placeholder="请输入分组名称"></el-input></el-form-item></el-form><template #footer><div class="dialog-footer"><el-button @click="handleCancel">取消</el-button><el-button type="primary" @click="handleSubmit"> 确定 </el-button></div></template></el-dialog></div>
</template><script setup>
import { onMounted, ref } from "vue";
import { Upload } from "@element-plus/icons-vue";
import { post, get, del, put } from "@/utils/http";const tableData = ref([]);
const total = ref(0);
// 查询条件
const queryParams = ref({group_name: undefined,startTime: undefined,endTime: undefined,page: 1,size: 10,group_id: undefined,
});
const daterange = ref([]);
const shortcuts = [{text: "最近一周",value: () => {const end = new Date();const start = new Date();start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);return [start, end];},},{text: "最近一个月",value: () => {const end = new Date();const start = new Date();start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);return [start, end];},},{text: "最近三个月",value: () => {const end = new Date();const start = new Date();start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);return [start, end];},},
];
// 获取分组列表
const getList = () => {get("/group/list", queryParams.value).then((res) => {tableData.value = res.data;total.value = res.total;});
};
// 搜索
const handleSearch = () => {queryParams.value.startTime = daterange.value[0];queryParams.value.endTime = daterange.value[1];console.log(queryParams.value);getList();
};
// 重置
const handleReset = () => {queryParams.value = {group_name: undefined,startTime: undefined,endTime: undefined,page: 1,size: 10,};daterange.value = [];getList();
};
// 切换页
const handleCurrentChange = (page) => {queryParams.value.page = page;getList();
};const dialogVisible = ref(false); //弹出对话框是否显示
const fileList = ref([]);
const ruleFormRef = ref(null);
const formData = ref({group_name: "",
});
// 校验规则
const rules = {group_name: [{ required: true, message: "请输入分组名称", trigger: "blur" }],
};
// 点击取消按钮
const handleCancel = () => {dialogVisible.value = false;// 重置表单formData.value = {group_name: "",};
};
const currentItem = ref({});
// 点击确定按钮
const handleSubmit = () => {// 先进行表单的验证ruleFormRef.value.validate((valid) => {if (valid) {if (title.value == "编辑分组") {put("/group/edit", formData.value).then((res) => {ElMessage.success("编辑成功");dialogVisible.value = false;formData.value = { group_name: "" };getList();});} else {post("/group/add", formData.value).then((res) => {ElMessage.success("上传成功");dialogVisible.value = false;formData.value = { group_name: "" };getList();});}}});
};// 删除
const handleDelete = (item) => {del(`/group/delete/${item.id}`).then((res) => {if (res.msg == "ok") {ElMessage.success("删除成功");} else {ElMessage.error(res.msg);}getList();});
};
const title = ref("添加分组");
// 添加
const handleAdd = () => {dialogVisible.value = true;formData.value = { group_name: "" };title.value = "添加分组";
};
// 编辑
const handleEdit = (item) => {dialogVisible.value = true;formData.value = item;title.value = "编辑分组";currentItem.value = item;
};onMounted(() => {getList();
});
</script><style lang="scss" scoped>
.el-table {margin-top: 20px;
}
.el-button.upload-btn {height: 100%;background: #f56c6c;color: #fff;&:hover {background: #f89898;color: #fff;}
}
.el-input-group__append > div {display: flex;
}
.videoStyle {width: 250px;height: 40px;
}
</style>

👉 后端

var express = require('express');
var router = express.Router();
const connection = require('../config/db.config')// 添加分组
router.post('/add', (req, res) => {const { group_name } = req.bodyconst insertSql = 'insert into block (group_name) values (?)'connection.query(insertSql, [group_name], (err, result) => {if (err) {console.error('添加分组失败:', err.message);return;}res.send({status: 200,msg: 'ok'})})
})// 获取分组列表
router.get('/list', (req, res) => {let { group_name, page, size, startTime, endTime, isAll } = req.querystartTime = startTime ? (new Date(startTime)).toLocaleDateString() : ''endTime = endTime ? (new Date(endTime)).toLocaleDateString() : ''let insertSql = ''// 按照名称查询并按照id进行倒叙排列if (group_name && !startTime) {insertSql = `select * from block where group_name like '%${group_name}%' order by id desc`} else if (!group_name && startTime) {// 按照时间查询并按照id进行倒叙排列insertSql = `select * from block where Date(create_time) between '${startTime}' and '${endTime}' order by id desc`} else if (group_name && startTime) {// 按照名称设时间查询并按照id进行倒叙排列insertSql = `select * from block where group_name like '%${group_name}%' and Date(create_time) between '${startTime}' and '${endTime}' order by id desc`} else {insertSql = `select * from block order by id desc`}connection.query(insertSql, (err, result) => {if (err) {console.error('获取分组列表失败:', err.message);return;}const data = result.map(item => {item.create_time = item.create_time.toLocaleString()return item})res.send({status: 200,msg: 'ok',data: isAll ? data : data.slice((page - 1) * size, page * size),total: result.length})})
})// 删除分组
router.delete('/delete/:id', (req, res) => {const { id } = req.paramsconst selectSql = `select * from block where id = ?`const deleteSql = `delete from block where id = ?`connection.query(selectSql, [id], (err, result) => {const num = result[0].numif (num > 0) {res.send({status: 200,msg: '分组下有联系人,无法删除'})} else {connection.query(deleteSql, (err, result) => {if (err) {console.error('删除分组失败:', err.message);return;}res.send({status: 200,msg: 'ok'})})}})
})// 编辑分组
router.put('/edit', (req, res) => {const { id, group_name } = req.bodyconst updateSql = `update block set group_name = ? where id = ?`connection.query(updateSql, [group_name, id], (err, result) => {if (err) {console.error('编辑分组失败:', err.message);return;}res.send({status: 200,msg: 'ok'})})
})module.exports = router;

五、视频混剪

5.1 功能描述

👉 搜索

在搜索栏中输入 视频名称、创建时间 后点击搜索按钮即可,也可以只输入一个条件搜索。

👉 添加

点击页面上的【创建混剪】按钮,然后在弹出的上传视频对话框中输入 视频名称、选择 背景音乐(选填)视频素材、视频尺寸设置一次性生成视频数量、设置生成视频的时长,点击确定按钮即可实现视频的混剪操作。

👉 删除

点击页面中的【删除】按钮,即可删除对应的视频文件。

5.2 效果展示

视频混剪页面
点击上一张图片中创建混剪按钮弹出的对话框
点击上一个图片中背景音乐栏中的选择按钮弹出的对话框
点击上上张图片中视频素材栏中的选择按钮弹出的对话框

填写所需要的信息之后点击确定按钮开始生成视频
处理完毕后的效果

5.3 代码实现

👉 前端

<template><div><el-button type="primary" @click="handleAdd">创建混剪</el-button><el-row style="margin: 20px 0"><el-form v-model="queryParams" inline><el-form-item label="视频名称"><el-inputv-model="queryParams.name"placeholder="输入要查询的文件名称"style="width: 200px"></el-input></el-form-item><el-form-item label="创建时间"><el-date-pickerv-model="daterange"type="daterange"unlink-panelsrange-separator="至"start-placeholder="开始日期"end-placeholder="结束日期":shortcuts="shortcuts"/></el-form-item></el-form><div style="margin-left: 20px"><el-button type="primary" @click="handleSearch">搜索</el-button><el-button type="info" plain @click="handleReset">重置</el-button></div></el-row><el-table :data="tableData" stripe border><el-table-column prop="id" label="ID" align="center" width="80" /><el-table-column prop="name" label="视频名称" align="center" /><el-table-columnprop="path"label="视频文件"align="center"min-width="320"><template #default="scope"><video :src="scope.row.path" controls class="videoStyle"></video></template></el-table-column><el-table-column prop="duration" label="视频时长(秒)" align="center" /><el-table-columnprop="measure"label="视频尺寸"align="center"min-width="100"/><el-table-column prop="duration" label="视频大小" align="center"><template #default="scope"> {{ scope.row.size }}M </template></el-table-column><el-table-column label="是否有背景音乐" align="center"><template #default="scope"><el-tag :type="scope.row.bgmId ? 'success' : 'info'">{{ scope.row.bgmId ? "有" : "无" }}</el-tag></template></el-table-column><el-table-columnprop="create_time"label="创建时间"align="center"width="200"/><el-table-column label="操作" align="center" min-width="92" fixed="right"><template #default="scope"><el-button type="danger" @click="handleDelete(scope.row)">删除</el-button></template></el-table-column></el-table><el-row style="margin-top: 20px; justify-content: end; align-items: center"><span style="margin-right: 20px; color: #606266">共 {{ total }} 条</span><el-paginationbackgroundlayout="prev, pager, next":page-size="queryParams.size":total="total"@current-change="handleCurrentChange"></el-pagination></el-row><!-- 创建混剪对话框 --><el-dialog v-model="dialogVisible" :title="title"><el-formv-loading="loading"element-loading-text="视频处理中...":model="formData":rules="rules"ref="ruleFormRef"label-width="102"><el-form-item label="视频名称" prop="name"><el-inputv-model="formData.name"placeholder="请输入视频名称"></el-input></el-form-item><el-form-item label="背景音乐"><el-inputv-model="store.bgm.path"placeholder="请选择背景音乐"class="input-with-select"disabled><template #append><el-button:icon="Select"class="choose-btn":disabled="isDisabled"type="primary"@click="handleChooseAudio">选择</el-button></template></el-input><div class="audioStyle" v-if="store.bgm.path"><audio :src="store.bgm.path" controls style="height: 40px"></audio><el-buttontype="danger":icon="Delete"@click="handleDeleteAudio"/></div></el-form-item><el-form-item label="视频素材" prop="path"><el-inputv-model="videosPath"placeholder="请选择视频素材"class="input-with-select"disabled><template #append><el-button:icon="Select"class="choose-btn":disabled="isDisabled"@click="handleChooseVideo">选择</el-button></template></el-input><div v-if="store.videoList.length" class="selectVideo"><div v-for="(item, index) in store.videoList" class="sv-item"><video:src="item.path"controlsstyle="width: 140px; height: 120px"></video><el-buttontype="danger":icon="Delete"@click="handleDeleteVideo(index)"/></div></div></el-form-item><el-form-item label="视频尺寸" prop="measure"><el-radio-group v-model="formData.measure"><el-radio:value="item.id"v-for="item in measureList":label="item.id == 2 ? item.label + '【推荐】' : item.label"/></el-radio-group></el-form-item><el-form-item label="生成视频数" prop="count"><!-- min:最小值max:最大值precision:精确到小数点后几位数,0表示整数--><div style="color: #909399"><el-input-numberv-model="formData.count":min="1":max="3":precision="0"/><div>一次最多能生成3个视频</div></div></el-form-item><el-form-item label="视频时长(秒)" prop="duration"><div style="color: #909399"><el-input-numberv-model="formData.duration":min="5":max="60":precision="0"/><div>每个视频的最大时长为60</div></div></el-form-item></el-form><template #footer><div class="dialog-footer"><el-button @click="handleCancel">取消</el-button><el-button type="primary" @click="handleSubmit"> 确定 </el-button></div></template></el-dialog><!-- 选择背景音乐、视频素材对话框 --><el-dialogv-model="store.audioDialog":title="title"width="70%"style="margin: 5vh auto 0"><AudioMaterial :choose="true" v-if="isAudio" :tableHeight="595" /><VideoMaterial :choose="true" v-else :tableHeight="595" /><template #footer><div class="dialog-footer"><el-button @click="handleAudioCancel">取消</el-button></div></template></el-dialog></div>
</template><script setup>
import { computed, onMounted, ref } from "vue";
import { Select, Delete } from "@element-plus/icons-vue";
import { post, get, del, put } from "@/utils/http";
import AudioMaterial from "@/views/material/AudioMaterial.vue";
import VideoMaterial from "@/views/material/VideoMaterial.vue";
import { useStore } from "@/store";const store = useStore();
const tableData = ref([]);
const total = ref(0);
// 查询条件
const queryParams = ref({name: undefined,startTime: undefined,endTime: undefined,page: 1,size: 10,group_id: "",
});
const daterange = ref([]);
const shortcuts = [{text: "最近一周",value: () => {const end = new Date();const start = new Date();start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);return [start, end];},},{text: "最近一个月",value: () => {const end = new Date();const start = new Date();start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);return [start, end];},},{text: "最近三个月",value: () => {const end = new Date();const start = new Date();start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);return [start, end];},},
];
// 获取视频列表
const getList = () => {get("/montage/list", queryParams.value).then((res) => {tableData.value = res.data;total.value = res.total;});
};
// 搜索
const handleSearch = () => {queryParams.value.startTime = daterange.value[0];queryParams.value.endTime = daterange.value[1];getList();
};
// 重置
const handleReset = () => {queryParams.value = {name: undefined,startTime: undefined,endTime: undefined,page: 1,size: 10,};daterange.value = [];getList();
};
// 切换页
const handleCurrentChange = (page) => {queryParams.value.page = page;getList();
};const dialogVisible = ref(false); //弹出对话框是否显示
const ruleFormRef = ref(null);
const title = ref("上传视频");
const videosPath = computed(() => {const path = store.videoList.map((item) => item.path).join(",");formData.value.path = path;return path;
});
const formData = ref({name: undefined,path: undefined,measure: 2,count: 1,duration: 10,
});
// 校验规则
const rules = {name: [{ required: true, message: "请输入视频名称", trigger: "blur" }],path: [{ required: true, message: "请选择视频文件", trigger: "blur" }],measure: [{ required: true, message: "请选择视频尺寸", trigger: "blur" }],count: [{ required: true, message: "请选择视频文件", trigger: "blur" }],duration: [{ required: true, message: "请选择视频尺寸", trigger: "blur" }],
};
const isAudio = ref(true); //当前点击的是否为选择音频的按钮,默认是选择音频
// 选择背景音乐
const handleChooseAudio = () => {isAudio.value = true;store.setAudioDialog(true);
};
// 取消选择背景音乐、视频素材
const handleAudioCancel = () => {store.setAudioDialog(false);store.chooseBgm("");store.setSelect(false);
};
// 删除选择的背景音乐
const handleDeleteAudio = () => {store.chooseBgm(""); //将仓库中的背景音乐清空
};
// 选择视频素材
const handleChooseVideo = () => {isAudio.value = false;store.setAudioDialog(true);store.setSelect(true);
};
const isDisabled = ref(false);
// 上传视频按钮
const handleAdd = () => {isDisabled.value = false;dialogVisible.value = true;// 清除上次的表单校验ruleFormRef.value.resetFields();title.value = "上传视频";
};
// 点击取消按钮
const handleCancel = () => {dialogVisible.value = false;// 重置表单formData.value = {name: undefined,path: undefined,measure: 2,count: 1,duration: 10,};// 重置表单校验ruleFormRef.value.resetFields();store.chooseBgm({}); //将仓库中的背景音乐清空store.clearVideoList();
};
// 删除选中的视频素材
const handleDeleteVideo = (index) => {store.deleteVideoList(index); //将仓库中指定index的视频素材删除
};
// 是否处于加载状态
const loading = ref(false);
// 点击确定按钮
const handleSubmit = () => {// 先进行表单的验证ruleFormRef.value.validate((valid) => {if (valid) {loading.value = true; //开启加载状态const params = {name: formData.value.name, //视频混剪名称bgmId: store.bgm.id, //背景音乐idvideoIds: store.videoList.map((item) => item.id).join(","), //视频素材idmeasureId: formData.value.measure, //视频尺寸count: formData.value.count, //视频数量duration: formData.value.duration, //视频时长};post("/montage/process", params).then((res) => {ElMessage.success("剪辑成功");loading.value = false; //关闭加载状态dialogVisible.value = false;getList();store.chooseBgm({}); //将仓库中的背景音乐清空store.clearVideoList(); // 将仓库中选择的视频素材清空formData.value = {name: undefined,path: undefined,measure: 2,count: 1,duration: 10,};});}});
};// 上传文件之前
const beforeUpload = (file) => {formData.value.path = URL.createObjectURL(file);formData.value.file = file;return false;
};
// 删除
const handleDelete = (item) => {del(`/montage/delete/${item.id}`).then((res) => {ElMessage.success("删除成功");getList();});
};
const measureList = ref([]);
// 获取视频尺寸
const getMeasure = () => {get("/measure/list").then((res) => {measureList.value = res.data;});
};
onMounted(() => {getList();getMeasure();
});
</script><style lang="scss" scoped>
.el-table {margin-top: 20px;
}
.el-button.choose-btn {height: 100%;background: #e6a23c;color: #fff;&:hover {background: #eebe77;color: #fff;}
}
.el-input-group__append > div {display: flex;
}
.videoStyle {width: 281px;height: 157px;
}
.audioStyle {margin-top: 10px;display: flex;align-items: center;
}
.selectVideo {margin-top: 10px;display: flex;flex-wrap: wrap;.sv-item {margin: 5px;display: flex;flex-direction: column;border: 1px solid #eee;border-radius: 0 0 4px 4px;.el-button {margin-top: 5px;font-size: 18px;}}
}
</style>

👉 后端

var express = require('express');
var router = express.Router();
const ffmpeg = require('fluent-ffmpeg');
const path = require('path');
const connection = require('../config/db.config')
const baseURL = 'http://localhost:3000'
const { promisify } = require('util')
const ffprobe = promisify(ffmpeg.ffprobe)const fs = require('fs');// 获取视频列表
router.get('/list', (req, res) => {let { name, startTime, endTime, page, size } = req.querystartTime = startTime ? (new Date(startTime)).toLocaleDateString() : ''endTime = endTime ? (new Date(endTime)).toLocaleDateString() : ''// console.log("混剪视频列表", { name, startTime, endTime, page, size })let selectSql = ''// 按照名称查询并按照id进行倒序排列if (name && !startTime) {selectSql = `select * from montage where name like '%${name}%' order by id desc`} else if (!name && startTime) {// 按照上传时间并按照id进行倒序排列selectSql = `select * from montage where Date(create_time) between '${startTime}' and '${endTime}' order by id desc`} else if (name && startTime) {// 按照时间时间和名称并按照id进行倒序排列selectSql = `select * from montage where name like '%${name}%' and Date(create_time) between '${startTime}' and '${endTime}' order by id desc`} else {selectSql = 'select * from montage order by id desc'}connection.query(selectSql, (err, results) => {if (err) {console.error('查询数据失败:', err.message);res.send({status: 500,msg: err.message})return;}const data = results.map(item => {item.path = baseURL + item.pathitem['create_time'] = item['create_time'].toLocaleString()return item})res.send({status: 200,msg: 'ok',data: data.slice((page - 1) * size, page * size),total: results.length})})
})// 根据传入的音频和视频进行视频的混剪操作
router.post('/process', (req, res) => {const { name, bgmId, videoIds, measureId, count, duration } = req.body// 查询视频输出的尺寸const selectMeasureSql = `select * from measures where id = ?`let measure = ''connection.query(selectMeasureSql, [measureId], (err, result) => {if (err) {console.error('查询测量数据失败:', err.message);return res.status(500).json({ error: '查询测量数据失败' });} else {measure = result[0].labelconcatVideo()}})// 查询背景音乐的路径const selectAudioSql = `select path from audio where id = ?`let bgmPath = ''//背景音乐路径let bgmDuration = 0//背景音乐时长// 查询视频素材的路径const selectVideoSql = `select path from video where id in (?)`let videoInfo = []//视频素材信息【路径+时长】// 创建 processed 目录(如果不存在)if (!fs.existsSync("public/processed")) {fs.mkdirSync("public/processed");}function concatVideo() {// 如果背景音乐不为空,则查询背景音乐的路径,并计算背景音乐的时长if (bgmId) {connection.query(selectAudioSql, [bgmId], async (err, result) => {if (err) {console.error('查询背景音乐失败:', err.message);return res.status(500).json({ error: '查询背景音乐失败' });}bgmPath = baseURL + result[0].path// 计算背景音乐的时长const metadata = await ffprobe(bgmPath)bgmDuration = metadata.format.durationselcetVideoInfo()})} else {selcetVideoInfo()}}// 查询视频素材路径并计算其时长function selcetVideoInfo() {connection.query(selectVideoSql, [videoIds.split(',')], async (err, result) => {if (err) {console.error('查询视频素材失败:', err.message);return res.status(500).json({ error: '查询视频素材失败' });}await Promise.all(result.map(async (item, index) => {videoInfo.push({ path: path.dirname(__dirname).replace(/\\/g, '/') + '/public' + item.path })// 计算视频素材时长try {const metadata = await ffprobe(baseURL + item.path);const videoDuration = parseFloat(metadata.format.duration);videoInfo[index].duration = videoDuration} catch (err) {console.error('获取视频素材时长失败:', err.message);}})).then(() => {const promises = []// 混剪视频逻辑for (let i = 0; i < count; i++) {promises.push(createConcatOutput(i, videoInfo))}Promise.all(promises).then(() => {console.log("视频处理完毕")res.send({status: 200,msg: '视频处理成功',});}).catch(err => {console.error(`处理视频时出错: ${err}`);res.status(500).json({ error: '处理视频时发生错误' });});}).catch(err => {console.error(`计算视频长度的时候出错: ${err}`);res.status(500).json({ error: '处理视频时发生错误' });});})}// 创建合并文件,并输出视频function createConcatOutput(index, videos) {// 要合并的视频片段文件存放的路径const concatFilePath = path.resolve('public', `concat-${index}.txt`).replace(/\\/g, '/');//绝对路径return new Promise((resolve, reject) => {const shuffledVideoPaths = shuffleArray([...videos])//打乱顺序的视频素材路径// 输出视频的路径,文件名为当前的时间戳,防止文件被覆盖const outputPath = path.join('public/processed', `${new Date().getTime()}-${index}.mp4`);// 合并文件的内容let concatFileContent = '';// 定义一个函数来随机选择视频片段function getRandomSegment(videoPath, length, segmentLength) {// 如果该素材的长度小于截取的长度,则直接返回整个视频素材if (segmentLength >= length) {return {videoPath,startTime: 0,endTime: length};}const startTime = Math.floor(Math.random() * (length - segmentLength));return {videoPath,startTime,endTime: startTime + segmentLength};}// 随机选择视频片段const segments = [];let totalLength = 0;// 初始分配for (let i = 0; i < shuffledVideoPaths.length; i++) {const videoPath = shuffledVideoPaths[i].path;const length = shuffledVideoPaths[i].duration;const segmentLength = Math.min(duration / shuffledVideoPaths.length, length);// 参数:视频路径,素材长度,要截取的长度const segment = getRandomSegment(videoPath, length, segmentLength);segments.push(segment);totalLength += (segment.endTime - segment.startTime);}/* 这段代码的主要作用是在初始分配后,如果总长度 totalLength 小于目标长度 targetLength,则通过不断从剩余的视频素材中随机选择片段来填补剩余的时间,直到总长度达到目标长度为止。每次循环都会计算剩余需要填补的时间,并从随机选择的视频素材中截取一段合适的长度。*/// 如果总长度小于目标长度,则从剩余素材中继续选取随机片段while (totalLength < duration) {// 计算还需要多少时间才能达到目标长度const remainingTime = duration - totalLength;// 从素材路径数组中随机选择一个视频素材的索引const videoIndex = Math.floor(Math.random() * shuffledVideoPaths.length);// 根据随机选择的索引,获取对应的视频路径和长度const videoPath = shuffledVideoPaths[videoIndex].path;const length = shuffledVideoPaths[videoIndex].duration;// 确定本次需要截取的长度// 这个长度不能超过剩余需要填补的时间,也不能超过素材本身的长度,因此选取两者之中的最小值const segmentLength = Math.min(remainingTime, length);// 生成新的视频片段const segment = getRandomSegment(videoPath, length, segmentLength);// 将新生成的视频片段对象添加到片段数组中segments.push(segment);// 更新总长度totalLength += (segment.endTime - segment.startTime);}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 loopCount = Math.floor(totalVideoDuration / bgmDuration);const ffmpegProcess = ffmpeg().input(concatFilePath).inputOptions(['-f concat', '-safe 0']).output(outputPath).outputOptions(['-y', // 覆盖已存在的输出文件'-c:v libx264', // 视频编码器'-preset veryfast', // 编码速度'-crf 23', // 视频质量控制'-map 0:v', // 选择所有输入文件的视频流(如果有)'-c:a aac', // 音频编码器'-b:a 128k', // 音频比特率'-t', totalVideoDuration.toFixed(2), // 设置输出文件的总时长为视频的时长`-s ${measure}`, // 设置输出的分辨率大小]).on('error', (err) => {console.error(`Error processing videos: ${err}`);console.error('FFmpeg stderr:', err.stderr);reject(err);return}).on('end', () => {// 生成文件的大小const size = (fs.statSync(outputPath).size / (1024 * 1024)).toFixed(2);const insertValues = [name, outputPath.replace('public', '').replace(/\\/g, '/'), duration, size, measure, videoIds, bgmId];let insertSql = '';if (bgmId) {insertSql = `insert into montage(name,path,duration,size,measure,videoIds,bgmId) values(?,?,?,?,?,?,?)`} else {insertSql = `insert into montage(name,path,duration,size,measure,videoIds) values(?,?,?,?,?,?)`}connection.query(insertSql, insertValues, (err) => {if (err) {console.error('插入数据失败:', err.message);reject(err);return}resolve();});});/* 如果有背景音乐,则添加音频文件作为输入并设置音频循环次数;否则使用原音频流作为输出*/if (bgmPath) {ffmpegProcess.input(bgmPath) // 添加音频文件作为输入.inputOptions([`-stream_loop ${loopCount}`]) // 设置音频循环次数.outputOptions(['-map 1:a']); // 选择第二个输入的音频流} else {ffmpegProcess.outputOptions(['-map 0:a']); // 使用原音频流作为输出}ffmpegProcess.run();});}
})// 打乱数组顺序
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;
}
// 删除视频
router.delete('/delete/:id', (req, res) => {const { id } = req.paramsconst deleteSql = `delete from montage where id=?`connection.query(deleteSql, [id], (err) => {if (err) {console.error('删除数据失败:', err.message);return res.status(500).json({ error: '删除数据时发生错误' });}res.send({status: 200,msg: "删除成功"})})
})module.exports = router;
关于ffmpeg部分代码的说明:

⭐ .inputOptions(['-f concat', '-safe 0']);

  • 这里设置了输入格式为concat,这通常用于合并多个输入文件到一个输出文件中。
  • -safe 0 表示关闭路径安全检查

⭐ .output(outputPath);

  • 设置输出文件的路径。

⭐ .outputOptions([

          '-y', // 覆盖已存在的输出文件

          '-c:v libx264', // 视频编码器

          '-preset veryfast', // 编码速度

          '-crf 23', // 视频质量控制

          '-map 0:v', // 选择所有输入文件的视频流(如果有)

          '-c:a aac', // 音频编码器

          '-b:a 128k', // 音频比特率

          '-t', totalVideoDuration.toFixed(2), // 设置输出文件的总时长为视频的时长

          `-s ${measure}`, // 设置输出的分辨率大小

        ])

  • 这里配置了一系列的输出选项,包括覆盖现有文件、视频编码器、编码预设、视频质量、视频流映射、音频编码器、音频比特率、输出时长以及输出分辨率等。

ffmpegProcess.run();

  • 启动FFmpeg进程开始视频处理。

写此篇文章目的是为刚开始接触FFmpeg的小伙伴们提供一个简单的案例,如果此案例有何不妥之处,还请各位批评指正!!!


http://www.ppmy.cn/embedded/133720.html

相关文章

力扣hot100-->递归/回溯

目录 递归/回溯 1. 17. 电话号码的字母组合 2. 22. 括号生成 3. 39. 组合总和 4. 46. 全排列 5. 78. 子集 递归/回溯 1. 17. 电话号码的字母组合 中等 给定一个仅包含数字 2-9 的字符串&#xff0c;返回所有它能表示的字母组合。答案可以按 任意顺序 返回。 给出数字到…

macOS 15 Sequoia dmg格式转用于虚拟机的iso格式教程

想要把dmg格式转成iso格式&#xff0c;然后能在虚拟机上用&#xff0c;最起码新版的macOS镜像是不能用UltraISO&#xff0c;dmg2iso这种软件了&#xff0c;你直接转放到VMware里绝对读不出来&#xff0c;办法就是&#xff0c;在Mac系统中转换为cdr&#xff0c;然后再转成iso&am…

Knife4j配置 ▎使用 ▎教程 ▎实例

knife4j简介 支持 API 自动生成同步的在线文档:使用 Swagger 后可以直接通过代码生成文档,不再需要自己手动编写接口文档了,对程序员来说非常方便,可以节约写文档的时间去学习新技术。 提供 Web 页面在线测试 API:光有文档还不够,Swagger 生成的文档还支持在线测试.参数和格式都…

docker部署SQL审核平台Archery

1、概述 Archery 是一个开源的 SQL 审核平台,专为数据库的 SQL 运维和管理而设计,广泛应用于企业的数据库运维工作中。其主要功能是帮助数据库管理员和开发人员实现 SQL 审核、SQL 执行、在线执行、查询、工单管理、权限控制等数据库管理相关的操作。 Archery 的主要功能包括…

pycharm 中提示ModuleNotFoundError: No module named ‘distutils‘

在Pycharm 中的命令行中输入 pip install setuptools&#xff0c;即可解决

智能工厂的设计软件 “word”篇、“power”篇和“task”篇

本文要点 在“智能工厂的设计软件”主题的最近这段时间里 除了两篇 比较零散的暂时还未归到某个“篇”两篇文章&#xff08;“表征论的三向度空间&#xff08;意向相关项&#xff09;”和“ 结构映射、类比推理及信念修正” &#xff09;外&#xff0c;其它讨论主要是&#xf…

在进行克隆虚拟机之后发现源主机无法进行正常的ping命令,自身IP也无法ping连通

最近在搭建LAMP架构时遇到了很多问题&#xff0c;解决了好长时间&#xff0c;以下是其中一个小问题 无法进行自身以及外界的ping连通 查看网络状态发现network没有打开 进行network的开启&#xff0c;发现提示错误 &#xff0c;按照提示的错误进行排错 后按照网上前辈们的相关经…

家具产品的耐用性新标准,矫平机为家具制造提供新保障

家具产品的耐用性新标准&#xff0c;矫平机为家具制造提供新保障 在家居市场竞争日益激烈的今天&#xff0c;消费者对家具产品的耐用性和质量要求越来越高。家具不仅是生活的必需品&#xff0c;更是家居美学的重要组成部分。因此&#xff0c;如何提升家具产品的耐用性&#xf…