vite+vue3中使用FFmpeg@0.12.15实现视频编辑功能,不依赖SharedArrayBuffer!!!
FFmpeg@0.12.15完全不依赖SharedArrayBuffer!!!强烈推荐使用
本文章主要是在vite+vue3项目中使用FFmpeg,只展示了如何在项目中引入和基础的使用
更多详细参数可参照 ffmpeg官网
https://ffmpeg.org/
一、安装FFmpeg
可通过npm直接安装
npm install @ffmpeg/core@0.12.10 @ffmpeg/ffmpeg@0.12.15 @ffmpeg/util@0.12.2
二、在代码中使用
1、初始化FFmpeg
首先找到node_modules/@ffmpeg/core/dist/esm下的这两个文件,并放到public文件夹下,例如
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { fetchFile, toBlobURL } from '@ffmpeg/util'
import { ref, onMounted } from 'vue'
let ffmpeg = null
const initFFmpegFn = async () => {if (ffmpeg) return// 初始化ffmpeg ffmpeg = new FFmpeg();// 如果需要当前编辑视频进度let progress = ref(0);ffmpeg.on('log', ({ type, message }) => {if (type === 'stderr') {// 匹配日志中的时间信息(例如:time=00:00:05.12)const timeMatch = message.match(/time=(\d+:\d+:\d+\.\d+)/);if (timeMatch && totalDuration.value) {const currentTime = parseTimeToSeconds(timeMatch[1]);const newProgress = Math.min(Math.round((currentTime / totalDuration.value) * 100), 100);progress.value = newProgress;}}});// 加载FFmpeg核心try {// ffmpeg.loaded 核心是否加载if (!ffmpeg.loaded) {let ffmpegBaseUrl = '/FFmpeg/dist'await ffmpeg.load({coreURL: await toBlobURL(`${ffmpegBaseUrl}/ffmpeg-core.js`, 'text/javascript'),wasmURL: await toBlobURL(`${ffmpegBaseUrl}/ffmpeg-core.wasm`, 'application/wasm'),});console.log('加载完成', ffmpeg);}} catch (err) {console.error('FFmpeg核心加载失败:', err);}}
// 工具函数:将时间字符串(如00:00:05.12)转换为秒
const parseTimeToSeconds = (timeStr) => {const [hours, minutes, seconds] = timeStr.split(':').map(Number);return hours * 3600 + minutes * 60 + seconds;
};
onMounted(() => {initFFmpegFn()
})
2、加载出错处理(如果通过上一步能正常加载完成,可忽略)
由于ffmpeg中会使用vite中的worker,可能会导致控制台中有一个链接为http://localhost:9090/node_modules/.vite/deps/worker.js?worker_file&type=module一直处于pending状态,无法加载成功
需要在vite.config.js中使用optimizeDeps.exclude
是指定不需要进行依赖预构建。
3、编辑视频:是否静音-调整视频宽高-裁剪视频时长
const processVideoFn = async (fileBlob, processOptions = {}) => {// fileBlob 视频文件blob对象if (!fileBlob || !(fileBlob instanceof Blob)) {console.error('错误:请传入有效的视频Blob对象');return { success: false, error: '无效的视频Blob', url: null, blob: null };}if (fileBlob.size === 0) {console.error('错误:传入的Blob为空(大小0字节)');return { success: false, error: '输入Blob为空', url: null, blob: null };}const {needClearVoice = false,//裁剪后是否静音resizeInfo = {width:800,height:800},//裁剪后视频宽高cropInfo = { startTime:0, duration:60 }// startTime:裁剪视频开始时间 duration:总裁剪时长} = processOptions;const { startTime = 0, duration = 60 } = cropInfo;progress.value = 0;//进度条归0totalDuration.value = duration;//总裁剪时长,用于计算进度条const timestamp = Date.now();const inputFileName = `input_video.mp4`;const outputFileName = `output_video_${timestamp}.mp4`;let outputData = null; // 存储输出数据,避免提前清理try {if (!ffmpeg || !loadSuccess.value) {throw new Error('FFmpeg核心未加载完成');}const fileData = await fetchFile(fileBlob); // fetchFile返回Uint8Arrayawait ffmpeg.writeFile(inputFileName, fileData); // 直接传文件名+数据const ffmpegCommand = ['-i', inputFileName];// 裁剪处理(校验参数)if (cropInfo) {if (startTime < 0 || duration <= 0) {throw new Error(`裁剪参数无效:startTime=${startTime}(需≥0),duration=${duration}(需>0)`);}ffmpegCommand.push('-ss', startTime.toFixed(2), '-t', duration.toFixed(2));}// 尺寸调整(校验参数)if (resizeInfo) {let { width, height } = resizeInfo;if (width <= 0 || height <= 0) {throw new Error(`尺寸参数无效:width=${width}(需>0),height=${height}(需>0)`);}const scaleFilter = `scale=w=${width}:h=${height}:force_original_aspect_ratio=decrease`;const padFilter = `pad=w=${width}:h=${height}:x=(ow-iw)/2:y=(oh-ih)/2:color=white`;//视频宽高不够时填充白色边框ffmpegCommand.push('-vf', `${scaleFilter},${padFilter}`);}// 音频处理if (needClearVoice) {ffmpegCommand.push('-an'); // 移除音频} else {ffmpegCommand.push('-c:a', 'aac', '-strict', 'experimental'); // 保留音频}ffmpegCommand.push('-c:v', 'libx264', // H.264编码(通用)'-pix_fmt', 'yuv420p', // 兼容所有播放器(避免仅支持yuv444p的问题)'-crf', '30', // 控制质量(值越小质量越高,28为平衡值)'-preset', 'ultrafast', // ultrafast/superfast/veryfast/faster/fast/medium(默认值)/slow/slower/veryslow 从前到后处理速度越来越慢,处理视频越精致'-b:v', '1M', // 视频比特率(避免输出过小/过大)'-y', // 覆盖已有文件outputFileName);await ffmpeg.exec(ffmpegCommand);outputData = await ffmpeg.readFile(outputFileName);if (!outputData || outputData.length === 0) {throw new Error('FFmpeg读取输出文件为空');}const resultBlob = new Blob([outputData], { type: 'video/mp4' });if (resultBlob.size === 0) {throw new Error('生成的Blob为空');}const previewUrl = URL.createObjectURL(resultBlob);return {url: previewUrl,//生成的临时视频urlblob: resultBlob,//生成的新视频blob对象};} catch (err) {console.error('视频处理失败:',err);} finally {if (ffmpeg && outputData) {if (await ffmpeg.listDir(inputFileName).catch(() => false)) {await ffmpeg.unlink(inputFileName).catch(err => console.warn('清理输入文件失败:', err));}if (await ffmpeg.listDir(outputFileName).catch(() => false) && outputData) { // 确保读取后再删除输出await ffmpeg.unlink(outputFileName).catch(err => console.warn('清理输出文件失败:', err));}}}
};