import path from 'path' import { errorMessage, successMessage } from '../Public/generalTools' import { CheckFileOrDirExist, CheckFolderExistsOrCreate, CopyFileOrFolder, DeleteFolderAllFile } from '../../define/Tools/file' import { MillisecondsToTimeString } from '../../define/Tools/time' import Ffmpeg from 'fluent-ffmpeg' import { SetFfmpegPath } from '../setting/ffmpegSetting' import fs from 'fs' import { GeneralResponse } from '../../model/generalResponse' const fspromises = fs.promises SetFfmpegPath() /** * FFmpeg 封装的一些操作 */ export class FfmpegOptions { ecode: string // 编码的方式 dcode: string // 解码的方式 constructor() { } InitCodec() { let videoCodec = 'libx264' // 默认编码器 if (global.gpu.type === 'NVIDIA') { videoCodec = 'h264_nvenc' } else if (global.gpu.type === 'AMD') { videoCodec = 'h264_amf' } this.ecode = videoCodec let videoDcodec = 'libx264' // 默认解码器 if (global.gpu.type === 'NVIDIA') { videoDcodec = 'h264_cuvid ' } else if (global.gpu.type === 'AMD') { videoDcodec = 'h264_amf' } this.dcode = videoDcodec } /** * 压缩视频文件 * @param videoPath 文件地址 * @param wh 宽高比 * @param crf crf值 * @returns */ async FfmpegCompressVideo(videoPath: string, maxV: number, crf: string): Promise { // 判断视频地址是不是存在 let videoIsExist = await CheckFileOrDirExist(videoPath) if (!videoIsExist) { throw new Error('视频地址对应的文件不存在') } if (!videoPath.toLowerCase().endsWith('.mp4')) { throw new Error("只支持MP4文件"); } let tempVideo = path.join(path.dirname(videoPath), "tmp.mp4"); await CopyFileOrFolder(videoPath, tempVideo); await fspromises.unlink(videoPath) let wh = await this.FfmpegGetVideoSize(tempVideo) if (wh.code != 1) { throw new Error(wh.message) } let rate = undefined let width = undefined let height = undefined // 计算尺寸 if (wh.data.width > wh.data.height) { rate = maxV / wh.data.width width = maxV height = wh.data.height * rate } else { height = maxV rate = maxV / wh.data.height width = wh.data.width * rate } return new Promise((resolve, reject) => { Ffmpeg(tempVideo) .outputOptions([ `-vf scale=${Math.floor(width)}:${Math.floor(height)}`, // 调整视频尺寸到 1280x720 `-b:v ${crf}` // 设置视频比特率为 1000kbps ]) .on('end', async () => { // 删除缓存文件 await fspromises.unlink(tempVideo) resolve('视频压缩处理完成'); }) .on('error', (err) => { reject('视频压缩处理失败: ' + err.toString()); }) .save(videoPath) }) } /** * FFmpeg裁剪视频,将一个视频将裁剪指定的时间内的片段 * @param {*} book 小说对象类 * @param {*} bookTask 小说批次任务对象类 * @param {*} startTime 开始时间 * @param {*} endTime 结束时间 * @param {*} videoPath 视频地址 * @param {*} outVideoFile 输出地址 * @returns */ async FfmpegCutVideo(startTime, endTime, videoPath, outVideoFile) { try { // 判断视频地址是不是存在 let videoIsExist = await CheckFileOrDirExist(videoPath) if (!videoIsExist) { throw new Error('视频地址对应的文件不存在') } // 判断开始时间和结束时间是不是合法 if (startTime == null || endTime == null) { throw new Error('开始时间和结束时间不能为空') } // 判断输出文件夹是不是存在 let outputFolder = path.dirname(outVideoFile) await CheckFolderExistsOrCreate(outputFolder) // 将时间转换为字符串 let startTimeString = MillisecondsToTimeString(startTime) let endTimeString = MillisecondsToTimeString(endTime) // 设置视频编码器 let videoCodec = 'libx264' // 默认编码器 if (global.gpu.type === 'NVIDIA') { videoCodec = 'h264_nvenc' } else if (global.gpu.type === 'AMD') { videoCodec = 'h264_amf' } // 判断分镜是不是和数据库中的数据匹配的上 let res = await new Promise((resolve, reject) => { Ffmpeg(videoPath) .outputOptions([ `-ss ${startTimeString}`, `-to ${endTimeString}`, '-preset fast', '-c:v ' + videoCodec, '-c:a copy' ]) .output(outVideoFile) .on('end', async function () { resolve(outVideoFile) }) .on('error', async function (err) { reject(new Error(`视频裁剪失败,错误信息如下:${err.toString()}`)) }) .run() }) let res_msg = `视频裁剪完成,输出地址:${outVideoFile}` return successMessage(res_msg, '视频裁剪成功', 'BasicReverse_FfmpegCutVideo') // 开始裁剪视频 } catch (error) { return errorMessage( '裁剪视频失败,错误信息如下: ' + error.message, 'BasicReverse_FfmpegCutVideo' ) } } /** * Ffmpeg提取音频 * @param {*} videoPath 视频地址 * @param {*} outAudioPath 输出音频地址 * @returns */ async FfmpegExtractAudio(videoPath: string, outAudioPath: string): Promise { try { // 判断视频地址是不是存在 let videoIsExist = await CheckFileOrDirExist(videoPath) if (!videoIsExist) { throw new Error('视频地址对应的文件不存在') } // 开始提取音频 let res = await new Promise((resolve, reject) => { Ffmpeg(videoPath) .output(outAudioPath) .audioCodec('libmp3lame') .audioBitrate('128k') .on('end', async function () { resolve(outAudioPath) }) .on('error', async function (err) { let res_msg = `音频提取失败,错误信息如下:${err.toString()}` reject(new Error(res_msg)) }) .run() }) let res_msg = `音频提取完成,输出地址:${res}` return successMessage(res, res_msg, 'BasicReverse_FfmpegExtractAudio') } catch (error) { return errorMessage( '提取音频失败,错误信息如下: ' + error.message, 'BasicReverse_FfmpegExtractAudio' ) } } /** * Ffmpeg提取视频指定时间的帧(只提取一帧) * @param {*} frameTime 视频的时间点 * @param {*} videoPath 视频地址 * @param {*} outFramePath 输出帧地址 */ async FfmpegGetFrame(frameTime: number, videoPath: string, outFramePath: string): Promise { try { let videoIsExist = await CheckFileOrDirExist(videoPath) if (videoIsExist == false) { throw new Error('视频地址对应的文件不存在') } // 判断输出文件夹是不是存在 let outputFolder = path.dirname(outFramePath) await CheckFolderExistsOrCreate(outputFolder) // 开始抽帧 // 判断分镜是不是和数据库中的数据匹配的上 let res = await new Promise((resolve, reject) => { Ffmpeg(videoPath) .inputOptions([`-ss ${MillisecondsToTimeString(frameTime)}`]) .output(outFramePath) .frames(1) .on('end', async function () { resolve(outFramePath) }) .on('error', async function (err) { reject(new Error(err.toString())) }) .run() }) let res_msg = `视频抽帧完成,输出地址:${res}` return successMessage(res, res_msg, 'BasicReverse_FfmpegGetFrame') } catch (error) { return errorMessage(error.message, 'BasicReverse_FfmpegGetFrame') } } /** * 获取视频文件的宽高 * @param {*} videoPath 视频文件地址 * @returns */ async FfmpegGetVideoSize(videoPath: string): Promise { try { let videoIsExist = await CheckFileOrDirExist(videoPath) if (videoIsExist == false) { throw new Error('视频地址对应的文件不存在') } let res = await new Promise((resolve, reject) => { Ffmpeg.ffprobe(videoPath, function (err, metadata) { if (err) { reject(new Error(err.toString())) } const { width, height } = metadata.streams.find((s) => s.codec_type === 'video') resolve({ width, height }) }) }) return successMessage(res, '获取视频的宽高成功', 'BasicReverse_GetVideoSize') } catch (error) { return errorMessage( '获取视频的宽高失败,失败信息如下:' + error.message, 'BasicReverse_GetVideoSize' ) } } /** * 通过FFmpeg获取指定视频的时长 * @param {*} videoPath 视频地址 * @returns 返回视频的时长 */ async FfmpegGetVideoDuration(videoPath) { try { let videoIsExist = await CheckFileOrDirExist(videoPath) if (videoIsExist == false) { throw new Error('视频地址对应的文件不存在') } let res = await new Promise((resolve, reject) => { Ffmpeg.ffprobe(videoPath, function (err, metadata) { if (err) { reject(new Error(err.toString())) } const duration = metadata.format.duration resolve(duration * 1000) }) }) return successMessage(res, '获取视频的时长成功', 'BasicReverse_GetVideoDuration') } catch (error) { return errorMessage( '获取视频的时长,失败信息如下:' + error.message, 'BasicReverse_GetVideoDuration' ) } } /** * 获取视频的指定时间点的一帧,然后再裁剪 * @param {*} videoPath * @param {*} currentTime * @param {*} outImagePath * @param {*} clipRanges */ async FfmpegGetVideoFramdAndClip(videoPath, currentTime, outImagePath, clipRanges) { try { let videoIsExist = await CheckFileOrDirExist(videoPath) if (videoIsExist == false) { throw new Error('视频地址对应的文件不存在') } // 判断输出文件夹是不是存在 let outputFolder = path.dirname(outImagePath) await CheckFolderExistsOrCreate(outputFolder) let frameRes = await this.FfmpegGetFrame(currentTime, videoPath, outImagePath) if (frameRes.code == 0) { throw new Error(frameRes.message) } let outImagePaths = [] if (await CheckFileOrDirExist(outImagePath) == false) { return successMessage( outImagePaths, '获取指定位置的帧和裁剪成功', 'WatermarkAndSubtitle_FfmpegGetVideoFramdAndClip' ) } // 这边可以会裁剪多个,所以需要循环 for (let i = 0; i < clipRanges.length; i++) { const element = clipRanges[i] let outCropImagePath = outImagePath.replace('.png', `_${i}.png`) outImagePaths.push(outCropImagePath) // 开始裁剪 let res = await new Promise((resolve, reject) => { Ffmpeg(outImagePath) .outputOptions([ `-vf crop=${element.width}:${element.height}:${element.startX}:${element.startY}` ]) .output(outCropImagePath) .on('end', async function () { resolve(outCropImagePath) }) .on('error', async function (err) { reject(new Error(err.toString())) }) .run() }) } // 删除文件 await fspromises.unlink(outImagePath) return successMessage( outImagePaths, '获取指定位置的帧和裁剪成功', 'WatermarkAndSubtitle_FfmpegGetVideoFramdAndClip' ) } catch (error) { return errorMessage( '获取指定位置的帧失败和裁剪失败,失败信息如下:' + error.toString(), 'WatermarkAndSubtitle_FfmpegGetVideoFramdAndClip' ) } } }