目录
[2.1 功能描述](#2.1 功能描述)
[👉 搜索](#👉 搜索)
[👉 添加](#👉 添加)
[👉 删除](#👉 删除)
[2.2 效果展示](#2.2 效果展示)
[2.3 代码实现](#2.3 代码实现)
[👉 前端](#👉 前端)
[👉 后端](#👉 后端)
[3.1 功能描述](#3.1 功能描述)
[👉 搜索](#👉 搜索)
[👉 添加](#👉 添加)
[👉 编辑](#👉 编辑)
[👉 删除](#👉 删除)
[3.2 效果展示](#3.2 效果展示)
[3.3 代码实现](#3.3 代码实现)
[👉 前端](#👉 前端)
[👉 后端](#👉 后端)
[4.1 功能描述](#4.1 功能描述)
[👉 搜索](#👉 搜索)
[👉 添加](#👉 添加)
[👉 编辑](#👉 编辑)
[👉 删除](#👉 删除)
[4.2 效果展示](#4.2 效果展示)
[4.3 代码实现](#4.3 代码实现)
[👉 前端](#👉 前端)
[👉 后端](#👉 后端)
[5.1 功能描述](#5.1 功能描述)
[👉 搜索](#👉 搜索)
[👉 添加](#👉 添加)
[👉 删除](#👉 删除)
[5.2 效果展示](#5.2 效果展示)
[5.3 代码实现](#5.3 代码实现)
[👉 前端](#👉 前端)
[👉 后端](#👉 后端)
一、简介
此篇文章带来的是 使用 FFmpeg实现视频混剪并且可以指定生成视频的数量以及生成视频的时长,此案例也是对前边几篇文章功能的综合。
说明:
此案例中的代码部分,前端使用的是 Vue + ElementPlus + Vite、后端使用的是 Nodejs + FFmpeg + MySQL
👇下方提供的代码为每一部分的核心代码,如果需要案例的完整代码,可以在评论区留言我来私信你!!!👆
二、音频素材页
2.1 功能描述
👉 搜索
在搜索栏中输入 音频名称、上传时间 后点击搜索按钮即可,也可以只输入一个条件搜索。
👉 添加
点击页面上的**【添加音频】**按钮,然后在弹出的上传音频对话框中输入 音频名称,并选择要上传的音频文件之后直接点击确定按钮即可完成音频文件的上传。
👉 删除
点击表格中的**【删除】**按钮即可删除对应的音频文件
2.2 效果展示
音频素材页面 点击上一张图片中的添加音频按钮弹出的对话框 选择要上传的音频文件
2.3 代码实现
👉 前端
html
<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-input
v-model="queryParams.name"
placeholder="输入要查询的文件名称"
style="width: 200px"
></el-input>
</el-form-item>
<el-form-item label="上传时间">
<el-date-picker
v-model="daterange"
type="daterange"
unlink-panels
range-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
: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-column
prop="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-column
prop="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-column
prop="upload_time"
label="上传时间"
align="center"
width="200"
/>
<el-table-column label="操作" align="center" width="90" fixed="right">
<template #default="scope">
<el-button
v-if="choose"
type="success"
size="small"
@click="handleChoose(scope.row)"
>选择</el-button
>
<el-button
v-else
type="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-pagination
background
layout="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-input
v-model="formData.name"
placeholder="请输入音频名称"
></el-input>
</el-form-item>
<el-form-item label="音频文件" prop="file">
<el-input
v-model="formData.path"
placeholder="请选择音频文件"
class="input-with-select"
disabled
>
<template #append>
<el-upload
v-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>
👉 后端
javascript
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.body
const 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.query
startTime = 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.path
item['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.params
const 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 代码实现
👉 前端
html
<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-input
v-model="queryParams.name"
placeholder="输入要查询的文件名称"
style="width: 200px"
></el-input>
</el-form-item>
<el-form-item label="视频分组">
<el-select
v-model="queryParams.group_id"
clearable
placeholder="请选择分组"
style="width: 200px"
>
<el-option
v-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-picker
v-model="daterange"
type="daterange"
unlink-panels
range-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-table
ref="tableRef"
:data="tableData"
stripe
border
@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-column
prop="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-column
prop="upload_time"
label="上传时间"
align="center"
width="200"
/>
<el-table-column label="操作" align="center" width="130" fixed="right">
<template #default="scope">
<el-button
v-if="choose"
type="success"
@click="handleChooseSingle(scope.row)"
>选择</el-button
>
<div style="display: flex; justify-content: space-between" v-else>
<el-button
type="success"
size="small"
@click="handleEdit(scope.row)"
>编辑</el-button
>
<el-button
type="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-pagination
background
layout="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-input
v-model="formData.name"
placeholder="请输入视频名称"
></el-input>
</el-form-item>
<el-form-item label="视频分组" prop="group_id">
<el-select
v-model="formData.group_id"
clearable
placeholder="请选择分组"
style="width: 200px"
>
<el-option
v-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-input
v-model="formData.path"
placeholder="请选择视频文件"
class="input-with-select"
disabled
>
<template #append>
<el-upload
v-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; //编辑的时候显示对话框
// 点击编辑的时候将当前点击的行赋值给currentItem
formData.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>
👉 后端
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 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.body
const 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.query
startTime = 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.path
item['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.body
const 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.body
console.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 代码实现
👉 前端
javascript
<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-input
v-model="queryParams.group_name"
placeholder="输入要查询的文件名称"
style="width: 200px"
></el-input>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="daterange"
type="daterange"
unlink-panels
range-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-column
prop="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-button
type="success"
size="small"
@click="handleEdit(scope.row)"
>编辑</el-button
>
<el-button
type="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-pagination
background
layout="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-input
v-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>
👉 后端
javascript
var express = require('express');
var router = express.Router();
const connection = require('../config/db.config')
// 添加分组
router.post('/add', (req, res) => {
const { group_name } = req.body
const 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.query
startTime = 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.params
const selectSql = `select * from block where id = ?`
const deleteSql = `delete from block where id = ?`
connection.query(selectSql, [id], (err, result) => {
const num = result[0].num
if (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.body
const 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 代码实现
👉 前端
html
<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-input
v-model="queryParams.name"
placeholder="输入要查询的文件名称"
style="width: 200px"
></el-input>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="daterange"
type="daterange"
unlink-panels
range-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-column
prop="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-column
prop="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-column
prop="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-pagination
background
layout="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
v-loading="loading"
element-loading-text="视频处理中..."
:model="formData"
:rules="rules"
ref="ruleFormRef"
label-width="102"
>
<el-form-item label="视频名称" prop="name">
<el-input
v-model="formData.name"
placeholder="请输入视频名称"
></el-input>
</el-form-item>
<el-form-item label="背景音乐">
<el-input
v-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-button
type="danger"
:icon="Delete"
@click="handleDeleteAudio"
/>
</div>
</el-form-item>
<el-form-item label="视频素材" prop="path">
<el-input
v-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"
controls
style="width: 140px; height: 120px"
></video>
<el-button
type="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-number
v-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-number
v-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-dialog
v-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, //背景音乐id
videoIds: store.videoList.map((item) => item.id).join(","), //视频素材id
measureId: 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>
👉 后端
javascript
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.query
startTime = 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.path
item['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].label
concatVideo()
}
})
// 查询背景音乐的路径
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.duration
selcetVideoInfo()
})
} 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.params
const 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的小伙伴们提供一个简单的案例,如果此案例有何不妥之处,还请各位批评指正!!!