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

#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-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>

👉 后端

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 代码实现

👉 前端

<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>

👉 后端

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 代码实现

👉 前端

<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>

👉 后端

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 代码实现

👉 前端

<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>

👉 后端

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的小伙伴们提供一个简单的案例,如果此案例有何不妥之处,还请各位批评指正!!!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/899626.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【2024CANN训练营第二季】使用华为云体验AscendC_Sample仓算子运行

环境介绍 NPU&#xff1a;Ascend910B2 环境准备 创建Notebook 华为云选择&#xff1a;【控制台】-【ModelArts】 ModelArts主页选择【开发生产】-【开发空间】-【Notebook】 页面右上角选择【创建Notebook】 选择资源 主要参数 规格&#xff1a;Ascend: 1*ascend-snt…

微搭低代码学习1:不同页面传递值

这个系列逐渐学习低代码平台&#xff0c;补足因为技术栈不足带来的问题&#xff0c;同时借助低代码平台快速搭建成型的系统。 这个博客用来记录一个非常常见的操作&#xff0c;在两个页面/多个页面之间传递值 文章目录 1. 创建页面2. 添加逻辑主动跳转页逻辑设置数据接收页逻辑…

【数据结构与算法】之栈详解

栈&#xff08;Stack&#xff09;是一种基本的线性数据结构&#xff0c;遵循后进先出、先进后出的原则。本文将更详细地介绍栈的概念、特点、Java 实现以及应用场景。 1. 栈概念概述 想象一摞叠放的盘子&#xff0c;你只能从最上面取盘子&#xff0c;放盘子也只能放在最上面。…

html和css实现页面

任务4 html文件 任务5 htm文件 css文件 任务6 html文件 css文件 任务7 html文件 css文件

工业交换机的电源类型

工业交换机的电源通常有以下几种类型和注意事项&#xff1a; 1. 电源类型&#xff1a; 交流电源&#xff08;AC&#xff09;&#xff1a;一些工业交换机使用标准的AC电源&#xff0c;通常是110V或220V。适用于有稳定电源环境的场合。 直流电源&#xff08;DC&#xff09;&#…

javaWeb项目-ssm+jsp大学生校园兼职系统功能介绍

本项目源码&#xff08;点击下方链接下载&#xff09;&#xff1a;java-ssmjsp大学生校园兼职系统实现源码(项目源码-说明文档)资源-CSDN文库 项目关键技术 开发工具&#xff1a;IDEA 、Eclipse 编程语言: Java 数据库: MySQL5.7 框架&#xff1a;ssm、Springboot 前端&#x…

使用Selenium时,如何模拟正常用户行为?

Selenium作为自动化测试和网页数据抓取的利器&#xff0c;被广泛应用于自动化网页交互、爬虫开发等领域。然而&#xff0c;随着网站反爬虫技术的不断升级&#xff0c;简单的自动化脚本很容易被识别和阻止。因此&#xff0c;模拟正常用户行为&#xff0c;降低被检测的风险&#…

springmvc+jdk1.8升级到springboot3+jdk17(实战)

1.查找springboot3官方要求 这里查的是springboot 3.2.6版本的 2.升级jdk到17 Java EE 8之后&#xff0c;Oracle在19年把javax捐给了eclipse基会&#xff0c;但不允许使用javax的命名空间&#xff0c;所以eclipse才发展成为现在的Jakarta ee标准。Springboot3后使用Jakarta a…

HTML简单版的体育新闻案例

代码&#xff1a; <!DOCTYPE html> <html lang"en"> <head> <meta charset"UTF-8"> <meta name"viewport" content"widthdevice-width, initial-scale1.0"> <title>Document</title> &l…

使用QT绘图控件QCustomPlot绘制波形图

使用QT绘图控件QCustomPlot绘制波形图 下载QCustomPlot 下载QCustomPlot,链接路径 解压之后就能看到源代码了 在Qt中添加QCustomPlot的帮助文档 在Qt Creator的菜单:工具–>选项–>帮助–>文档–>添加qcustomplot\documentation\qcustomplot.qch文件。

windbg调试exedump步骤,技巧总结

所有信息参考官方文档&#xff1a;开始使用 WinDbg&#xff08;用户模式&#xff09; - Windows drivers | Microsoft Learn 需要着重关注的标签页如下&#xff1a; 用户模式&#xff08;入门&#xff09; 命令摘要 Help 菜单上的命令 Contents.sympath&#xff08;设置符号…

解锁PDF权限密码

目录 背景: 定义与功能&#xff1a; 过程&#xff1a; 主要功能&#xff1a; 使用方式&#xff1a; 使用限制&#xff1a; 注意事项&#xff1a; 总结&#xff1a; 背景: 前段时间自己设置了PDF文件的许可口令&#xff0c;忘了口令导致自己无法编辑内容等&#xff0c;这…

OpenCV和HALCON

OpenCV和HALCON是两种广泛用于图像处理和计算机视觉的开发库&#xff0c;它们各有优缺点&#xff0c;适合不同的应用场景。以下是两者的比较&#xff1a; 1. 开发背景与定位 OpenCV (Open Source Computer Vision Library)&#xff1a; 开源库&#xff0c;最初由Intel开发&…

Matlab中计算道路曲率的几种方法

我使用Prescan采集到的道路中心线数据&#xff0c;都是离散点&#xff08;x&#xff0c;y&#xff0c;z&#xff09;&#xff0c;但在作研究时&#xff0c;通常都是道路曲率&#xff0c;这时需要将离散点坐标转换为曲率&#xff0c;但通过计算得到的曲率与实际曲率有一些误差&a…

sentinel原理源码分析系列(八)-熔断

限流为了防止过度使用资源造成系统不稳&#xff0c;熔断是为了识别出”坏”资源&#xff0c;避免好的资源受牵连(雪崩效应)&#xff0c;是保证系统稳定性的关键&#xff0c;也是资源有效使用的关键&#xff0c;sentinel熔断插槽名称Degrade(降级)&#xff0c;本人觉得应该改为熔…

怎么提取pdf的某一页?批量提取pdf的某一页的简单方法

怎么提取pdf的某一页&#xff1f;在日常工作与学习中&#xff0c;我们经常会遇到各式各样的PDF文件&#xff0c;它们以其良好的兼容性和稳定性&#xff0c;成为了信息传输和存储的首选格式。然而&#xff0c;在浩瀚的文档海洋中&#xff0c;有时某个PDF文件中的某一页内容尤为重…

一篇文章进阶MySQL数据库

一&#xff0c;MySQL数据库体系结构 层级说明连接层主要完成一些类似于连接处理&#xff0c;授权认证&#xff0c;及相关的安全方案。服务器也会为安全接入的每个客户端验证它所具有的操作权限服务层完成大多数的核心服务功能&#xff0c;如SQL接口&#xff0c;并完成缓存的查询…

使用 Pake 一键打包网页为桌面应用 / 客户端

项目 项目&#xff1a;https://github.com/tw93/Pake/ 免费ICO图片&#xff1a;https://icon-icons.com/zh/ 设置环境 以下教程仅针对windows系统适用 请确保您的 Node.js 版本为 18 或更高版本 文档&#xff1a;https://v1.tauri.app/zh-cn/v1/guides/getting-started/prerequ…

java小游戏实战-星空大战(接口、继承、多态等多种方法)

环境&#xff1a;Windows系统Eclipse/idea、jdk8 1.创建英雄类 2.创建飞机类 3.创建敌人接口 package com.plane;public interface Enemy { /* *得分的方法 */ public int getScore(); } 4.创建小蜜蜂类 5.创建奖励接口 package com.plane;public interface Award {public …

【Linux笔记】Linux命令与使用

博文将不断学习补充 学习参考博文&#xff1a; Linux命令大全&#xff1a;掌握常用命令&#xff0c;轻松使用Linux操作系统-CSDN博客 文件或目录操作命令 zip # zip是使用最多的文档压缩格式 # 方便跨平台使用&#xff0c;但是压缩率不是很高 zip指令未安装 安装zip yum ins…