背景:
最近遇到一个需求:需要对长视频的音频做提取,最后对音频识别出文字,算法和后端都可以对视频进行视频切割和提取,那我就想前端能不能实现呢,就开始调研方案。
后端和算法使用的都是FFmpeg这个库实现视频的操作,其实咱们前端也有FFmpeg.js,能对视频进行操作。
一、FFmpeg.wasm 简介
先讲FFmpeg,FFmpeg 是一个开源的音视频处理工具,支持录制、转换和流式传输音视频。它提供了丰富的功能,如格式转换、视频剪辑、音频提取等。
FFmpeg官网链接;其本身是一个开源的客户端工具,同时也提供了多种语言的api支持。
FFmpeg.wasm是c语言编写的程序,通过编译后可以在web平台上使用(浏览器),
原理:WebAssembly(简称wasm)是一个虚拟指令集体系架构(virtual ISA),整体架构包括核心的ISA定义、二进制编码、程序语义的定义与执行,以及面向不同的嵌入环境(如Web)的应用编程接口(WebAssembly API)。其目标是为 C/C++等语言编写的程序经过编译,在确保安全和接近原生应用的运行速度更好地在 Web 平台上运行。
简单来说:WebAssembly 是一种高效的二进制格式,能够在现代浏览器中快速执行。
运行原理:
-
加载和初始化: 当用户在浏览器中加载
ffmpeg.js
时,浏览器会下载编译后的 WebAssembly 模块和相关的 JavaScript 代码。 -
调用 FFmpeg 功能: 用户可以通过 JavaScript 调用 FFmpeg 的命令和功能,传递参数和数据。
ffmpeg.js
会将这些调用转换为相应的 WebAssembly 函数调用。 -
处理音视频: FFmpeg 在浏览器中执行音视频处理任务,使用虚拟文件系统读取输入文件,进行处理,然后将结果输出到虚拟文件系统中。
-
下载结果: 处理完成后,用户可以通过 JavaScript 下载处理后的文件,所有操作都在客户端完成,无需与服务器交互。
二、常用FFmpeg方法
ffmpeg.js
提供了一系列方法来处理音视频文件。以下是一些常用的方法和功能:
1. 基本方法
-
FFmpeg.load()
:- 加载 FFmpeg 的核心库。这个方法需要在使用其他 FFmpeg 功能之前调用。
-
FFmpeg.run(...args)
:- 执行 FFmpeg 命令。参数是一个字符串数组,表示要执行的命令及其参数。例如:
await ffmpeg.run('-i', 'input.mp4', 'output.avi');
- 执行 FFmpeg 命令。参数是一个字符串数组,表示要执行的命令及其参数。例如:
2. 文件操作
-
FFmpeg.FS(command, filename, data)
:- 在虚拟文件系统中执行文件操作。常用的命令包括:
writeFile
: 将数据写入虚拟文件系统。readFile
: 从虚拟文件系统读取文件。unlink
: 删除虚拟文件系统中的文件。
// 写入文件 ffmpeg.FS('writeFile', 'input.mp4', await fetchFile('path/to/local/file.mp4')); // 读取文件 const data = ffmpeg.FS('readFile', 'output.avi');
- 在虚拟文件系统中执行文件操作。常用的命令包括:
3. 音视频处理
-
格式转换:
- 使用
run
方法进行格式转换。例如,将 MP4 转换为 AVI:await ffmpeg.run('-i', 'input.mp4', 'output.avi');
- 使用
-
视频剪辑:
- 可以通过指定时间戳来剪辑视频。例如,剪辑从 10 秒到 20 秒的片段:
await ffmpeg.run('-i', 'input.mp4', '-ss', '10', '-to', '20', 'output.mp4');
- 可以通过指定时间戳来剪辑视频。例如,剪辑从 10 秒到 20 秒的片段:
-
音频提取:
- 从视频中提取音频:
await ffmpeg.run('-i', 'input.mp4', '-q:a', '0', '-map', 'a', 'output.mp3');
- 从视频中提取音频:
4. 视频压缩和调整
-
视频压缩:
- 可以通过调整比特率来压缩视频:
await ffmpeg.run('-i', 'input.mp4', '-b:v', '1000k', 'output.mp4');
- 可以通过调整比特率来压缩视频:
-
调整分辨率:
- 改变视频的分辨率:
await ffmpeg.run('-i', 'input.mp4', '-vf', 'scale=640:360', 'output.mp4');
- 改变视频的分辨率:
5. 其他功能
-
添加水印:
- 可以在视频上添加水印:
await ffmpeg.run('-i', 'input.mp4', '-i', 'watermark.png', '-filter_complex', 'overlay=10:10', 'output.mp4');
- 可以在视频上添加水印:
-
合并视频:
- 合并多个视频文件:
await ffmpeg.run('-i', 'concat:input1.mp4|input2.mp4', '-c', 'copy', 'output.mp4');
- 合并多个视频文件:
6. 获取处理结果
- 获取处理后的文件:
- 使用
readFile
方法获取处理后的文件数据,并可以将其转换为 Blob 以便下载:const data = ffmpeg.FS('readFile', 'output.mp4'); const blob = new Blob([data.buffer], { type: 'video/mp4' }); const url = URL.createObjectURL(blob);
- 使用
三、具体功能实现
需求:
本地选择长视频,允许对长视频选择时间段裁剪,音频提取,图片帧提取。
1、ffmpeg引入:
npm install vue video.js @ffmpeg/ffmpeg
这里需要注意,安装依赖的时候我出现报错了 ,所以参考FFmpeg安装避坑,最后有效安装。
步骤:
1、手动在package.json文件中加入配置后下载
2、将nodemodules中的这三个文件复制到pubilc下
启动项目之后可以使用ffmpeg的能力了
2、切割视频
/**
* 切割视频方法
* 使用FFmpeg对视频进行时间段切割
* 步骤:
* 1. 设置处理状态并加载FFmpeg
* 2. 获取原视频文件并写入FFmpeg文件系统
* 3. 执行FFmpeg命令进行切割
* 4. 读取切割后的视频并创建下载链接
* 5. 将结果添加到处理文件列表中
*/
async cutVideo() {
try {
// 设置处理状态
this.processing = true;
this.processingStatus = '正在切割视频...';
// 确保FFmpeg已加载
await this.loadFFmpeg();
// 获取视频文件并写入FFmpeg虚拟文件系统
const videoBlob = await fetch(this.videoSource).then(r => r.blob());
ffmpeg.FS('writeFile', 'input.mp4', await fetchFile(videoBlob));
// 执行FFmpeg命令进行视频切割
// -i: 输入文件
// -ss: 开始时间点(秒)
// -t: 持续时间(秒)
// -c copy: 复制编解码器(不重新编码,速度快)
await ffmpeg.run(
'-i', 'input.mp4',
'-ss', `${this.startTime}`,
'-t', `${this.endTime - this.startTime}`,
'-c', 'copy',
'output.mp4'
);
// 从FFmpeg文件系统读取切割后的视频
const data = ffmpeg.FS('readFile', 'output.mp4');
// 创建Blob对象用于下载
const blob = new Blob([data.buffer], { type: 'video/mp4' });
// 将处理结果添加到文件列表
this.processedFiles.push({
name: 'cut_video.mp4', // 文件名
type: 'video', // 文件类型
blob: blob, // 文件数据
url: this.createDownloadUrl(blob) // 下载链接
});
} catch (error) {
console.error('视频切割失败:', error);
} finally {
// 重置处理状态
this.processing = false;
}
},
打印如下:
结果如下:
3、提取音频
* 提取音频方法
* 使用FFmpeg从视频中提取音频轨道并转换为MP3格式
* 步骤:
* 1. 设置处理状态并加载FFmpeg
* 2. 从processedFiles中获取已处理的视频文件
* 3. 将视频写入FFmpeg文件系统
* 4. 执行FFmpeg命令提取音频
* 5. 读取提取的音频并创建下载链接
* 6. 将结果添加到处理文件列表中
*/
async extractAudio() {
try {
// 设置处理状态
this.processing = true;
this.processingStatus = '正在提取音频...';
// 确保FFmpeg已加载
await this.loadFFmpeg();
// 从处理文件列表中获取视频文件
const videoFile = this.processedFiles.find(f => f.type === 'video');
if (!videoFile) throw new Error('未找到视频文件');
// 将视频文件写入FFmpeg虚拟文件系统
ffmpeg.FS('writeFile', 'input.mp4', await fetchFile(videoFile.blob));
// 执行FFmpeg命令提取音频
// -i: 输入文件
// -vn: 禁用视频输出
// -acodec libmp3lame: 使用LAME编码器将音频编码为MP3格式
await ffmpeg.run(
'-i', 'input.mp4',
'-vn',
'-acodec', 'libmp3lame',
'audio.mp3'
);
// 从FFmpeg文件系统读取提取的音频
const data = ffmpeg.FS('readFile', 'audio.mp3');
// 创建Blob对象用于下载
const blob = new Blob([data.buffer], { type: 'audio/mp3' });
// 将处理结果添加到文件列表
this.processedFiles.push({
name: 'extracted_audio.mp3', // 文件名
type: 'audio', // 文件类型
blob: blob, // 文件数据
url: this.createDownloadUrl(blob) // 下载链接
});
} catch (error) {
// 错误处理
console.error('音频提取失败:', error);
} finally {
// 重置处理状态
this.processing = false;
}
},
音频提取打印:
结果如下:
4、视频帧提取
* 从视频中提取关键帧
* 使用FFmpeg每60秒提取一帧图像,并保存为JPEG格式
* 提取的帧会被添加到processedFiles数组中供后续使用
*/
async extractFrames() {
try {
// 设置处理状态为正在进行中
this.processing = true;
this.processingStatus = '正在抽取关键帧...';
// 确保FFmpeg已加载完成
await this.loadFFmpeg();
// 从处理文件列表中查找视频文件
const videoFile = this.processedFiles.find(f => f.type === 'video');
if (!videoFile) throw new Error('未找到视频文件');
// 将视频文件写入FFmpeg虚拟文件系统
ffmpeg.FS('writeFile', 'input.mp4', await fetchFile(videoFile.blob));
// 执行FFmpeg命令提取帧
// -i: 指定输入文件
// -vf fps=1/60: 设置视频过滤器,每60秒提取1帧
// -frame_pts 1: 在输出文件名中包含显示时间戳
// frame_%d.jpg: 输出文件名格式,_%d会被替换为帧序号
await ffmpeg.run(
'-i', 'input.mp4',
'-vf', 'fps=1/60',
'-frame_pts', '1',
'frame_%d.jpg'
);
// 存储提取的帧
const frames = [];
// 遍历提取的帧,最多提取maxFrames个
for (let i = 1; i <= this.maxFrames; i++) {
try {
// 从FFmpeg文件系统读取帧数据
const data = ffmpeg.FS('readFile', `frame_${i}.jpg`);
// 创建Blob对象用于预览和下载
const blob = new Blob([data.buffer], { type: 'image/jpeg' });
// 将帧信息添加到数组
frames.push({
name: `frame_${i}.jpg`, // 帧文件名
type: 'image', // 文件类型
blob: blob, // 帧数据
url: this.createDownloadUrl(blob) // 创建下载链接
});
} catch (e) {
// 如果读取失败,说明没有更多帧了,退出循环
break;
}
}
// 将提取的帧添加到处理文件列表
this.processedFiles.push(...frames);
} catch (error) {
// 错误处理
console.error('帧提取失败:', error);
} finally {
// 重置处理状态
this.processing = false;
}
}
},
结果如下:
遇到的问题:
在引入之后发现跨域问题:ReferenceError: SharedArrayBuffer is not defined
别慌:在vue.config.js中增加下面的配置:(我使用的是vite,webpack自行修改)
devServer: {
headers: {
"Cross-Origin-Opener-Policy": "same-origin", // 保护你的源站点免受攻击
"Cross-Origin-Embedder-Policy": "require-corp", // 保护受害者免受你的源站点的影响
},
}
上线后还有这样的问题,增加nginx配置:
add_header Cross-Origin-Opener-Policy same-origin;
add_header Cross-Origin-Embedder-Policy require-corp;
add_header Cross-Origin-Resource-Policy same-origin;
完整代码:
<div class="video-player">
<div class="file-input-container" v-if="!videoSource">
<div
class="upload-area"
@click="triggerFileInput"
@dragover.prevent
@drop.prevent="handleDrop"
>
<input
type="file"
ref="fileInput"
@change="onFileChange"
accept="video/*"
class="hidden-input"
/>
<div class="upload-content">
<div class="upload-icon">
<i class="fas fa-cloud-upload-alt"></i>
</div>
<h3>选择或拖拽视频文件到此处</h3>
<p>支持的格式: MP4, MOV, AVI, etc.</p>
<button class="select-button">选择视频文件</button>
</div>
</div>
</div>
<div v-if="videoSource">
<h2 class="video-title" :title="allOriginalFileName">
{{ allOriginalFileName }}
</h2>
<div class="player-container">
<video
ref="videoPlayer"
class="video-js vjs-default-skin"
controls
preload="auto"
width="500"
height="380"
>
<source :src="videoSource" type="video/mp4" />
你的浏览器不支持video标签
</video>
</div>
<div class="controls" v-if="videoSource">
<button @click="playPause">{{ isPlaying ? "暂停" : "播放" }}</button>
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
@input="onSeek"
class="seek-bar"
/>
<span>{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
<button @click="fastForward">快进</button>
<button @click="rewind">快退</button>
</div>
<div class="time-segment-container">
<div class="time-segment-label">分析时间段:</div>
<!-- 添加时间段选择控件 -->
<div class="time-selection" v-if="videoSource">
<div class="time-input">
<label>开始时间:</label>
<input type="number" v-model="startTime" min="0" :max="duration" />
秒
</div>
<div class="time-input">
<label>结束时间:</label>
<input
type="number"
v-model="endTime"
:min="startTime"
:max="duration"
/>
秒
</div>
</div>
</div>
<!-- 添加功能按钮 -->
<div class="function-buttons" v-if="videoSource">
<button @click="cutVideo" :disabled="!canCut">模型分析视频</button>
<button @click="resetVideo">重新上传视频</button>
</div>
<!-- 处理状态和结果展示 -->
<div v-if="processing" class="processing-status">
<div>处理中:{{ processingStatus }}</div>
<div class="loader">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
</div>
<div v-if="processedFiles.length" class="result-files">
<div class="result-files-title">处理结果:</div>
<ul>
<li v-for="(file, index) in processedFiles" :key="index">
{{ file.name }} - {{ file.type }}
<a v-if="file.url" :href="file.url" download>下载</a>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import "video.js/dist/video-js.css";
import videojs from "video.js";
import FFmpeg from "@ffmpeg/ffmpeg";
const { createFFmpeg, fetchFile } = FFmpeg;
import JSZip from "jszip";
import CryptoJS from "crypto-js"; // 引入 crypto-js
const ffmpeg = createFFmpeg({
corePath: "./ffmpeg-core.js", // 核心文件的路径
log: true, // 是否在控制台打印日志,true => 打印
});
import EventBus from "./EventBus";
import "./LeftUpload.css"; // 引入 CSS 文件
export default {
name: "HelloWorld",
props: {
msg: String,
},
data() {
return {
videoSource: null,
isPlaying: false,
currentTime: 0,
duration: 0,
player: null,
startTime: 0, // 切割开始时间
endTime: 0, // 切割结束时间
processing: false, // 处理状态
processingStatus: "", // 处理状态描述
processedFiles: [], // 处理结果文件列表
ffmpegLoaded: false, // ffmpeg加载状态
originalFileName: "", // 存储原始文件名
allOriginalFileName: "",
fileCounter: {
mp4: 0,
mp3: 0,
image: 0,
zip: 0,
},
};
},
computed: {
/**
* 判断是否可以进行视频切割
* 条件:
* 1. 开始时间小于结束时间
* 2. 结束时间不超过视频总时长
* 3. 当前没有正在进行的处理任务
* @returns {boolean} 是否可以进行切割
*/
canCut() {
return (
this.startTime < this.endTime &&
this.endTime <= this.duration &&
!this.processing
);
},
/**
* 判断是否可以进行视频处理
* 条件:
* 1. 已处理文件列表中存在视频类型文件
* 2. 当前没有正在进行的处理任务
* @returns {boolean} 是否可以进行处理
*/
canProcess() {
return (
this.processedFiles.some((f) => f.type === "video") && !this.processing
);
},
},
created() {
this.loadFFmpeg();
},
methods: {
// 添加重置视频方法
/**
* 重置视频播放器和相关状态
* 步骤:
* 1. 销毁现有的播放器实例
* 2. 释放视频源URL占用的内存
* 3. 重置所有视频相关的状态变量
* 4. 清空文件输入框
*/
resetVideo() {
// 销毁现有的播放器实例
if (this.player) {
this.player.dispose();
this.player = null;
}
// 释放视频源URL占用的内存
if (this.videoSource) {
URL.revokeObjectURL(this.videoSource);
}
// 重置视频播放相关状态
this.videoSource = null;
this.isPlaying = false;
this.currentTime = 0;
this.duration = 0;
this.startTime = 0;
this.endTime = 0;
// 重置处理相关状态
this.processedFiles = [];
this.processing = false;
this.processingStatus = "";
// 清空文件输入框
if (this.$refs.fileInput) {
this.$refs.fileInput.value = "";
}
},
/**
* 触发文件选择对话框
*/
triggerFileInput() {
this.$refs.fileInput.click();
},
/**
* 处理文件拖放事件
* 当用户拖放文件到上传区域时触发
* @param {DragEvent} event - 拖放事件对象
*/
handleDrop(event) {
const file = event.dataTransfer.files[0];
if (file && file.type.startsWith("video/")) {
this.handleVideoFile(file);
}
},
/**
* 处理文件选择变更事件
* 当用户通过文件选择对话框选择文件时触发
* @param {Event} event - 文件选择事件对象
*/
onFileChange(event) {
const file = event.target.files[0];
if (file) {
this.handleVideoFile(file);
}
},
// 处理视频文件的方法
async handleVideoFile(file) {
// 保存原始文件名(去掉扩展名)
this.originalFileName =
file.name.replace(/\.[^/.]+$/, "").slice(0, 10) + "...";
this.allOriginalFileName = file.name;
// 销毁之前的播放器实例
if (this.player) {
this.player.dispose();
this.player = null;
}
// 创建新的视频源URL
this.videoSource = URL.createObjectURL(file);
// 初始化新的播放器
this.$nextTick(() => {
this.initializePlayer();
});
// 生成视频的唯一MD5 ID
const md5Id = await this.generateMD5(file);
console.log("视频的唯一MD5 ID:", md5Id); // 打印MD5 ID
},
// 生成MD5
async generateMD5(file) {
const duration = 10; // 截取的时间长度(秒)
const videoBlob = new Blob([file]); // 创建 Blob 对象
const videoUrl = URL.createObjectURL(videoBlob); // 创建视频 URL
// 创建视频元素以获取时长
const videoElement = document.createElement("video");
videoElement.src = videoUrl;
return new Promise((resolve, reject) => {
videoElement.onloadedmetadata = async () => {
const videoDuration = videoElement.duration; // 获取视频时长
const maxDuration = Math.min(videoDuration, duration); // 取最小值
const start = 0; // 从文件开始读取
const end = Math.floor((maxDuration * file.size) / videoDuration); // 计算结束字节
const chunk = file.slice(start, end); // 截取文件的前部分
const arrayBuffer = await chunk.arrayBuffer(); // 将块读取为 ArrayBuffer
const bytes = new Uint8Array(arrayBuffer); // 转换为 Uint8Array
// 将 Uint8Array 转换为 WordArray
const wordArray = CryptoJS.lib.WordArray.create(bytes.buffer);
const md5 = CryptoJS.MD5(wordArray).toString(); // 计算 MD5
resolve(md5); // 返回 MD5 哈希
};
videoElement.onerror = (error) => {
reject(error); // 处理错误
};
});
},
generateFileName(type) {
// 增加计数器
this.fileCounter[type]++;
// 格式化时间段
const timeSegment = `_${Math.floor(this.startTime)}-${Math.floor(
this.endTime
)}s`;
console.log(this.originalFileName);
console.log(timeSegment);
console.log(this.fileCounter[type]);
// 根据类型生成不同的文件名
switch (type) {
case "mp4":
return `${this.originalFileName}${timeSegment}-${this.fileCounter[type]}.mp4`;
case "mp3":
return `${this.originalFileName}${timeSegment}-${this.fileCounter[type]}.mp3`;
case "image":
return `${this.originalFileName}${timeSegment}-${this.fileCounter[type]}.jpg`;
case "zip":
return `${this.originalFileName}${timeSegment}-${this.fileCounter[type]}.zip`;
default:
return `${this.originalFileName}_${type}_${this.fileCounter[type]}`;
}
},
/**
* 初始化视频播放器
* 设置视频加载完成和时间更新的事件监听
*/
initializePlayer() {
this.player = videojs(this.$refs.videoPlayer, {}, () => {
// 视频元数据加载完成时的处理
this.player.on("loadedmetadata", () => {
this.duration = Math.floor(this.player.duration());
this.endTime = this.duration;
});
// 视频播放时间更新时的处理
this.player.on("timeupdate", () => {
this.currentTime = this.player.currentTime();
});
});
},
/**
* 控制视频播放/暂停
* 切换播放状态并更新isPlaying标志
*/
playPause() {
if (this.player.paused()) {
this.player.play();
this.isPlaying = true;
} else {
this.player.pause();
this.isPlaying = false;
}
},
/**
* 处理视频进度条拖动事件
* @param {Event} event - 进度条输入事件
*/
onSeek(event) {
const time = event.target.value;
this.player.currentTime(time);
this.currentTime = time;
EventBus.$emit("updateCurrentTime", time);
},
/**
* 格式化时间显示
* @param {number} seconds - 需要格式化的秒数
* @returns {string} 格式化后的时间字符串 (mm:ss)
*/
formatTime(seconds) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
},
/**
* 快进10秒
* 确保不超过视频总时长
*/
fastForward() {
const newTime = Math.min(this.player.currentTime() + 10, this.duration);
this.player.currentTime(newTime);
},
/**
* 快退10秒
* 确保不小于0秒
*/
rewind() {
const newTime = Math.max(this.player.currentTime() - 10, 0);
this.player.currentTime(newTime);
},
/**
* 加载FFmpeg库
* 如果已加载则跳过
*/
async loadFFmpeg() {
// 检查FFmpeg是否已经加载
if (!this.ffmpegLoaded) {
await ffmpeg.load();
this.ffmpegLoaded = true;
} else {
console.log("FFmpeg is already loaded.");
}
},
/**
* 创建文件下载URL
* @param {Blob} blob - 文件blob对象
* @returns {string} 可下载的URL
*/
createDownloadUrl(blob) {
return URL.createObjectURL(blob);
},
/**
* 切割视频方法
* 使用FFmpeg对视频进行时间段切割
* 步骤:
* 1. 设置处理状态并加载FFmpeg
* 2. 获取原视频文件并写入FFmpeg文件系统
* 3. 执行FFmpeg命令进行切割
* 4. 读取切割后的视频并创建下载链接
* 5. 将结果添加到处理文件列表中
*/
/**
* 切割视频方法
* 使用FFmpeg对视频进行时间段切割
*/
// 版本2 并行处理
async cutVideo() {
const startTime = Date.now(); // Start timing
try {
console.log("开始切割视频处理");
this.processing = true;
this.processingStatus = "正在切割视频";
if (!this.ffmpegLoaded) {
await this.loadFFmpeg();
}
console.log("获取视频文件...");
const videoBlob = await fetch(this.videoSource).then((r) => r.blob());
console.log(`获取到视频文件大小: ${videoBlob.size} bytes`);
ffmpeg.FS("writeFile", "input.mp4", await fetchFile(videoBlob));
console.log("视频文件写入FFmpeg完成");
const segmentLength = 600; // 每段600秒
const totalSegments = Math.ceil(
(this.endTime - this.startTime) / segmentLength
);
console.log(`总共需要处理 ${totalSegments} 个片段`);
const audioFiles = [];
const frameFiles = [];
for (let i = 0; i < totalSegments; i++) {
const start = this.startTime + i * segmentLength;
const duration = Math.min(segmentLength, this.endTime - start);
console.log(
`开始处理第 ${
i + 1
} 个片段, 起始时间: ${start}秒, 持续时间: ${duration}秒`
);
// 切割视频
this.processingStatus = `正在切割视频段 ${i + 1}/${totalSegments}`;
console.log(`执行视频切割命令...`);
await ffmpeg.run(
"-i",
"input.mp4",
"-ss",
`${start}`,
"-t",
`${duration}`,
"-c",
"copy",
`output_segment_${i}.mp4`
);
console.log(`视频段 ${i + 1} 切割完成`);
// 提取音频
this.processingStatus = `正在提取音频段 ${i + 1}/${totalSegments}`;
console.log(`开始提取音频...`);
await ffmpeg.run(
"-i",
`output_segment_${i}.mp4`,
"-vn",
"-acodec",
"libmp3lame",
"-b:a",
"64k",
"-ar",
"8000",
`audio_segment_${i}.mp3`
);
console.log(`音频段 ${i + 1} 提取完成`);
audioFiles.push({
name: `audio_segment_${i}.mp3`,
blob: ffmpeg.FS("readFile", `audio_segment_${i}.mp3`),
});
// 提取关键帧
this.processingStatus = `正在提取关键帧段 ${i + 1}/${totalSegments}`;
const frameDir = `frames/frame_segment_${i}`;
// 确保目录存在
try {
if (!ffmpeg.FS("readdir", "/").includes("frames")) {
ffmpeg.FS("mkdir", "frames");
console.log("frames目录创建成功");
}
if (
!ffmpeg.FS("readdir", "frames").includes(`frame_segment_${i}`)
) {
ffmpeg.FS("mkdir", `frames/frame_segment_${i}`);
console.log(`frame_segment_${i}目录创建成功`);
}
} catch (error) {
console.log(`目录 ${frameDir} 创建失败或已存在,错误: ${error}`);
}
await ffmpeg.run(
"-i",
`output_segment_${i}.mp4`,
"-vf",
"fps=1", // 修改这里,设置每秒提取一帧
`${frameDir}/frame_%d.jpg`
);
// 读取关键帧文件
let j = 1;
let frameExists = true;
while (frameExists) {
const frameFileName = `${frameDir}/frame_${j}.jpg`;
try {
// const frameData = ffmpeg.FS("readFile", frameFileName);
const frameData = ffmpeg.FS("readFile", frameFileName);
const frameTime = new Date((start + j - 1) * 1000)
.toISOString()
.substr(11, 8)
.replace(/:/g, "-");
const newFrameName = `frame_${frameTime}.jpg`;
frameFiles.push({
name: newFrameName,
blob: new Blob([frameData.buffer], { type: "image/jpeg" }),
url: this.createDownloadUrl(
new Blob([frameData.buffer], { type: "image/jpeg" })
),
});
j++;
} catch (error) {
frameExists = false; // 如果读取失败,退出循环
}
}
}
// 处理完成后生成ZIP文件
this.processingStatus = "正在生成ZIP文件";
console.log(
`需要打包 ${audioFiles.length} 个音频文件和 ${frameFiles.length} 个关键帧文件`
);
const zip = new JSZip();
const audioFolder = zip.folder("audio");
const framesFolder = zip.folder("frames");
// 确保音频文件和关键帧文件都被添加到 ZIP 包中
audioFiles.forEach((file) => {
audioFolder.file(file.name, file.blob);
});
// 添加帧文件到 ZIP
frameFiles.forEach((file) => {
const framePath = file.name.split("/").slice(1, -1).join("/"); // 修改这里,去掉第一个frames目录
let subFolder = framesFolder.folder(framePath); // 创建或获取子文件夹
subFolder.file(file.name.split("/").pop(), file.blob); // 添加文件
});
const zipBlob = await zip.generateAsync({ type: "blob" });
console.log(`ZIP文件生成完成, 大小: ${zipBlob.size} bytes`);
const zipFileName = this.generateFileName("zip");
const zipUrl = this.createDownloadUrl(zipBlob);
this.processedFiles.push({
name: zipFileName,
type: "zip",
blob: zipBlob,
url: zipUrl,
});
this.processingStatus = "处理完成"; // 更新处理状态
this.processing = false;
console.log("视频处理全部完成");
const endTime = Date.now(); // End timing
const processingTime = (endTime - startTime) / 1000; // Calculate duration in seconds
console.log(`处理完成,总耗时: ${processingTime}秒`);
} catch (error) {
console.error("视频切割失败:", error);
console.log("错误详情:", error.message);
console.log("错误堆栈:", error.stack);
this.processing = false;
}
},
},
beforeUnmount() {
if (this.player) {
this.player.dispose();
}
// 清理所有创建的URL
if (this.videoSource) {
URL.revokeObjectURL(this.videoSource);
}
this.processedFiles.forEach((file) => {
if (file.url) {
URL.revokeObjectURL(file.url);
}
});
},
};
</script>
我的代码中压缩部分可略过 看提取代码即可 为了分割长视频效率,采用分割600s为一段,并行处理
后续我尝试一下前端实现音频文字的提取。
四、总结
ffmpeg.js
提供了丰富的功能来处理音视频文件,包括格式转换、剪辑、提取音频、压缩、调整分辨率、添加水印等。通过这些方法,开发者可以在浏览器中实现强大的音视频处理功能。 能够在浏览器中运行而不需要安装其他工具,主要是因为它利用了 WebAssembly 和 Emscripten 技术,将 FFmpeg 编译为可以在浏览器中高效执行的格式。通过虚拟文件系统和浏览器 API,ffmpeg.js
实现了音视频处理的完整功能,提供了一个强大的客户端解决方案。