mirror of
https://github.com/fofolee/uTools-quickcommand.git
synced 2025-06-07 21:46:12 +08:00
970 lines
24 KiB
JavaScript
970 lines
24 KiB
JavaScript
/**
|
||
* 运行FFmpeg命令并显示进度条
|
||
* @param {string[]} args FFmpeg命令参数
|
||
* @param {object} options 选项
|
||
* @param {string} options.title 进度条标题
|
||
* @param {string} options.text 进度条文本
|
||
* @param {string} options.position 进度条位置
|
||
* @param {function} options.onPause 暂停回调
|
||
* @param {function} options.onResume 恢复回调
|
||
* @param {boolean} options.isShowProcessBar 是否显示进度条
|
||
* @returns {Promise} 返回Promise
|
||
*/
|
||
async function runFFmpeg(args, options = {}) {
|
||
const {
|
||
title = "FFmpeg处理",
|
||
text = "处理中...",
|
||
position = "bottom-right",
|
||
onPause,
|
||
onResume,
|
||
isShowProcessBar = true,
|
||
} = options;
|
||
|
||
let ffmpegRun;
|
||
|
||
// 显示进度条
|
||
const processBar = isShowProcessBar
|
||
? await quickcommand.showProcessBar({
|
||
title,
|
||
text,
|
||
value: 0,
|
||
position,
|
||
onPause,
|
||
onResume,
|
||
onClose: () => {
|
||
// 关闭时终止FFmpeg
|
||
if (ffmpegRun) {
|
||
try {
|
||
ffmpegRun.quit();
|
||
} catch (error) {
|
||
console.log(error);
|
||
}
|
||
}
|
||
ffmpegRun = null;
|
||
},
|
||
})
|
||
: null;
|
||
|
||
// 运行FFmpeg
|
||
ffmpegRun = utools.runFFmpeg(args, (progress) => {
|
||
// 更新进度条
|
||
if (progress.percent !== undefined) {
|
||
const intProgress = Math.round(progress.percent);
|
||
if (processBar) {
|
||
quickcommand.updateProcessBar(
|
||
{
|
||
value: intProgress,
|
||
text: `${text} (${intProgress}%) - 速度: ${progress.speed}`,
|
||
},
|
||
processBar
|
||
);
|
||
}
|
||
} else {
|
||
// 如果没有进度百分比,显示已处理时间
|
||
if (processBar) {
|
||
quickcommand.updateProcessBar(
|
||
{
|
||
text: `${text} - 已处理: ${progress.time} - 速度: ${progress.speed}`,
|
||
},
|
||
processBar
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
return new Promise((resolve, reject) => {
|
||
ffmpegRun
|
||
.then(() => {
|
||
if (processBar) {
|
||
quickcommand.updateProcessBar(
|
||
{
|
||
value: 100,
|
||
text: "处理完成, 即将关闭进度条",
|
||
},
|
||
processBar
|
||
);
|
||
quickcommand.asyncSleep(500).then(() => {
|
||
processBar.close();
|
||
});
|
||
}
|
||
resolve();
|
||
})
|
||
.catch((error) => {
|
||
if (processBar) {
|
||
quickcommand.updateProcessBar(
|
||
{
|
||
text: `处理出错: ${error.message}`,
|
||
},
|
||
processBar
|
||
);
|
||
}
|
||
reject(error);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 压缩视频
|
||
* @param {string} input 输入文件路径
|
||
* @param {string} output 输出文件路径
|
||
* @param {object} options 压缩选项
|
||
* @param {string} options.encoder 视频编码器
|
||
* @param {string} options.preset 压缩预设
|
||
* @param {number} options.crf 视频质量(0-51)
|
||
* @param {string} options.resolution 分辨率(keep/3840:2160/2560:1440/1920:1080/1280:720/854:480)
|
||
* @param {boolean} options.overwrite 是否覆盖已存在的文件
|
||
*/
|
||
async function compressVideo(input, output, options = {}) {
|
||
const {
|
||
encoder = "libx264",
|
||
preset = "medium",
|
||
crf = 23,
|
||
resolution = "keep",
|
||
overwrite = false,
|
||
} = options;
|
||
|
||
const args = [];
|
||
|
||
// 覆盖参数必须在最前面
|
||
if (overwrite) {
|
||
args.push("-y");
|
||
}
|
||
|
||
args.push("-i", input);
|
||
|
||
// 如果需要调整分辨率
|
||
if (resolution !== "keep") {
|
||
const [width, height] = resolution.split(":");
|
||
args.push(
|
||
"-vf",
|
||
`scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`
|
||
);
|
||
}
|
||
|
||
args.push(
|
||
"-c:v",
|
||
encoder,
|
||
"-preset",
|
||
preset,
|
||
"-crf",
|
||
crf.toString(),
|
||
"-c:a",
|
||
"copy",
|
||
output
|
||
);
|
||
|
||
await runFFmpeg(args, {
|
||
title: "视频压缩",
|
||
text: "正在压缩视频...",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 视频转GIF
|
||
* @param {string} input 输入文件路径
|
||
* @param {string} output 输出文件路径
|
||
* @param {object} options 转换选项
|
||
* @param {number} options.fps 帧率
|
||
* @param {number} options.width 宽度
|
||
* @param {boolean} options.overwrite 是否覆盖已存在的文件
|
||
*/
|
||
async function convertToGif(input, output, options = {}) {
|
||
const { fps = 15, width = 480, overwrite = false } = options;
|
||
|
||
const args = [];
|
||
|
||
// 覆盖参数必须在最前面
|
||
if (overwrite) {
|
||
args.push("-y");
|
||
}
|
||
|
||
args.push(
|
||
"-i",
|
||
input,
|
||
"-vf",
|
||
`fps=${fps},scale=${width}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`,
|
||
"-loop",
|
||
"0",
|
||
output
|
||
);
|
||
|
||
await runFFmpeg(args, {
|
||
title: "GIF转换",
|
||
text: "正在转换为GIF...",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 提取音频
|
||
* @param {string} input 输入文件路径
|
||
* @param {string} output 输出文件路径
|
||
* @param {object} options 提取选项
|
||
* @param {number} options.quality 音频质量(0-9)
|
||
* @param {boolean} options.overwrite 是否覆盖已存在的文件
|
||
*/
|
||
async function extractAudio(input, output, options = {}) {
|
||
const { quality = 0, overwrite = false } = options;
|
||
|
||
const args = [];
|
||
|
||
// 覆盖参数必须在最前面
|
||
if (overwrite) {
|
||
args.push("-y");
|
||
}
|
||
|
||
args.push("-i", input, "-q:a", quality.toString(), "-map", "a", output);
|
||
|
||
await runFFmpeg(args, {
|
||
title: "音频提取",
|
||
text: "正在提取音频...",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 录制屏幕
|
||
* @param {string} output 输出文件路径
|
||
* @param {object} options 录制选项
|
||
* @param {number} options.fps 帧率
|
||
* @param {boolean} options.overwrite 是否覆盖已存在的文件
|
||
*
|
||
* utools接口目前好像有问题,无法结束录制
|
||
*/
|
||
async function recordScreen(output, options = {}) {
|
||
const { fps = 30, overwrite = false } = options;
|
||
|
||
const args = [];
|
||
|
||
// 覆盖参数必须在最前面
|
||
if (overwrite) {
|
||
args.push("-y");
|
||
}
|
||
|
||
// 根据操作系统选择录屏命令
|
||
if (process.platform === "win32") {
|
||
args.push("-f", "gdigrab", "-framerate", fps.toString(), "-i", "desktop");
|
||
} else if (process.platform === "darwin") {
|
||
args.push(
|
||
"-f",
|
||
"avfoundation",
|
||
"-framerate",
|
||
fps.toString(),
|
||
"-i",
|
||
"default"
|
||
);
|
||
} else {
|
||
args.push("-f", "x11grab", "-framerate", fps.toString(), "-i", ":0.0");
|
||
}
|
||
|
||
// 添加输出文件
|
||
args.push(output);
|
||
|
||
const ffmpegRun = utools.runFFmpeg(args);
|
||
|
||
return new Promise((resolve, reject) => {
|
||
ffmpegRun.catch((e) => {
|
||
quickcommand.showSystemMessageBox("录制失败: " + e.message);
|
||
reject(e);
|
||
});
|
||
|
||
quickcommand
|
||
.showSystemWaitButton({
|
||
text: "结束录制",
|
||
})
|
||
.then((confirm) => {
|
||
console.log("结束录制", confirm, ffmpegRun.quit);
|
||
if (confirm) {
|
||
ffmpegRun.quit();
|
||
resolve();
|
||
} else {
|
||
ffmpegRun.kill();
|
||
reject(new Error("录制取消"));
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 将时间字符串转换为秒数
|
||
* @param {string} timeStr 时间字符串 (格式: HH:mm:ss)
|
||
* @returns {number} 秒数
|
||
*/
|
||
function timeToSeconds(timeStr) {
|
||
if (typeof timeStr === "number") return timeStr;
|
||
|
||
const parts = timeStr.split(":").map(Number);
|
||
if (parts.length === 3) {
|
||
// HH:mm:ss
|
||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||
} else if (parts.length === 2) {
|
||
// mm:ss
|
||
return parts[0] * 60 + parts[1];
|
||
} else if (parts.length === 1) {
|
||
// ss
|
||
return parts[0];
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/**
|
||
* 截取视频片段
|
||
* @param {string} input 输入文件路径
|
||
* @param {string} output 输出文件路径
|
||
* @param {object} options 截取选项
|
||
* @param {string|number} options.start 开始时间(HH:mm:ss 或秒数)
|
||
* @param {string|number} options.duration 持续时长(HH:mm:ss 或秒数)
|
||
* @param {boolean} options.overwrite 是否覆盖已存在的文件
|
||
*/
|
||
async function cutVideo(input, output, options = {}) {
|
||
const {
|
||
start = "00:00:00",
|
||
duration = "00:00:10",
|
||
overwrite = false,
|
||
} = options;
|
||
|
||
const args = [];
|
||
|
||
// 覆盖参数必须在最前面
|
||
if (overwrite) {
|
||
args.push("-y");
|
||
}
|
||
|
||
// 转换时间格式为秒数
|
||
const startSeconds = timeToSeconds(start);
|
||
const durationSeconds = timeToSeconds(duration);
|
||
|
||
args.push(
|
||
"-i",
|
||
input,
|
||
"-ss",
|
||
startSeconds.toString(),
|
||
"-t",
|
||
durationSeconds.toString(),
|
||
"-c",
|
||
"copy",
|
||
output
|
||
);
|
||
|
||
await runFFmpeg(args, {
|
||
title: "视频剪切",
|
||
text: "正在截取片段...",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 旋转/翻转视频
|
||
* @param {string} input 输入文件路径
|
||
* @param {string} output 输出文件路径
|
||
* @param {object} options 选项
|
||
* @param {number} options.rotate 旋转角度(90/180/270)
|
||
* @param {boolean} options.flipH 水平翻转
|
||
* @param {boolean} options.flipV 垂直翻转
|
||
* @param {boolean} options.overwrite 是否覆盖已存在的文件
|
||
*/
|
||
async function rotateVideo(input, output, options = {}) {
|
||
const {
|
||
rotate = 0,
|
||
flipH = false,
|
||
flipV = false,
|
||
overwrite = false,
|
||
} = options;
|
||
|
||
const args = [];
|
||
if (overwrite) args.push("-y");
|
||
|
||
// 构建滤镜
|
||
let filter = "";
|
||
if (rotate) filter += `rotate=${(rotate * Math.PI) / 180}`;
|
||
if (flipH) filter += (filter ? "," : "") + "hflip";
|
||
if (flipV) filter += (filter ? "," : "") + "vflip";
|
||
|
||
args.push("-i", input, "-vf", filter, "-c:a", "copy", output);
|
||
|
||
await runFFmpeg(args, {
|
||
title: "视频旋转/翻转",
|
||
text: "正在处理视频...",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 添加水印
|
||
* @param {string} input 输入文件路径
|
||
* @param {string} watermark 水印图片路径
|
||
* @param {string} output 输出文件路径
|
||
* @param {object} options 选项
|
||
* @param {string} options.position 位置(topleft/topright/bottomleft/bottomright/center)
|
||
* @param {number} options.padding 边距
|
||
* @param {number} options.scale 缩放比例(0-1)
|
||
* @param {boolean} options.overwrite 是否覆盖已存在的文件
|
||
*/
|
||
async function addWatermark(input, watermark, output, options = {}) {
|
||
const {
|
||
position = "bottomright",
|
||
padding = 10,
|
||
scale = 0.1,
|
||
overwrite = false,
|
||
} = options;
|
||
|
||
const args = [];
|
||
if (overwrite) args.push("-y");
|
||
|
||
// 计算水印位置
|
||
let overlay;
|
||
switch (position) {
|
||
case "topleft":
|
||
overlay = `${padding}:${padding}`;
|
||
break;
|
||
case "topright":
|
||
overlay = `main_w-overlay_w-${padding}:${padding}`;
|
||
break;
|
||
case "bottomleft":
|
||
overlay = `${padding}:main_h-overlay_h-${padding}`;
|
||
break;
|
||
case "center":
|
||
overlay = "(main_w-overlay_w)/2:(main_h-overlay_h)/2";
|
||
break;
|
||
default: // bottomright
|
||
overlay = `main_w-overlay_w-${padding}:main_h-overlay_h-${padding}`;
|
||
}
|
||
|
||
args.push(
|
||
"-i",
|
||
input,
|
||
"-i",
|
||
watermark,
|
||
"-filter_complex",
|
||
`[1:v]scale=iw*${scale}:-1[watermark];[0:v][watermark]overlay=${overlay}`,
|
||
"-c:a",
|
||
"copy",
|
||
output
|
||
);
|
||
|
||
await runFFmpeg(args, {
|
||
title: "添加水印",
|
||
text: "正在添加水印...",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 合并视频
|
||
* @param {string[]} inputs 输入文件路径数组
|
||
* @param {string} output 输出文件路径
|
||
* @param {object} options 选项
|
||
* @param {boolean} options.overwrite 是否覆盖已存在的文件
|
||
*/
|
||
async function mergeVideos(inputs, output, options = {}) {
|
||
const { overwrite = false } = options;
|
||
|
||
// 先获取第一个视频的分辨率
|
||
let width, height;
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
utools.runFFmpeg(["-i", inputs[0]]).catch((error) => {
|
||
// FFmpeg 在获取视频信息时会返回错误,但错误信息中包含视频信息
|
||
const match = error.message.match(/(\d{2,5})x(\d{2,5})/);
|
||
if (match) {
|
||
width = match[1];
|
||
height = match[2];
|
||
resolve();
|
||
} else {
|
||
reject(new Error("无法获取视频分辨率"));
|
||
}
|
||
});
|
||
});
|
||
} catch (error) {
|
||
throw new Error("获取视频分辨率失败: " + error.message);
|
||
}
|
||
|
||
const args = [];
|
||
if (overwrite) args.push("-y");
|
||
|
||
// 构建复杂的filter_complex命令
|
||
let filterComplex = "";
|
||
|
||
// 添加所有输入文件
|
||
inputs.forEach((_, index) => {
|
||
args.push("-i", inputs[index]);
|
||
if (index === 0) {
|
||
// 第一个视频不做处理
|
||
filterComplex += `[0:v]setsar=1[v0];`;
|
||
filterComplex += `[0:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo[a0];`;
|
||
} else {
|
||
// 其他视频缩放到第一个视频的分辨率
|
||
filterComplex += `[${index}:v]scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1[v${index}];`;
|
||
filterComplex += `[${index}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo[a${index}];`;
|
||
}
|
||
});
|
||
|
||
// 添加concat
|
||
filterComplex += `${inputs
|
||
.map((_, i) => `[v${i}][a${i}]`)
|
||
.join("")}concat=n=${inputs.length}:v=1:a=1[outv][outa]`;
|
||
|
||
args.push(
|
||
"-filter_complex",
|
||
filterComplex,
|
||
"-map",
|
||
"[outv]",
|
||
"-map",
|
||
"[outa]",
|
||
"-c:v",
|
||
"libx264",
|
||
"-c:a",
|
||
"aac",
|
||
output
|
||
);
|
||
|
||
await runFFmpeg(args, {
|
||
title: "合并视频",
|
||
text: "正在合并视频...",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 视频调速
|
||
* @param {string} input 输入文件路径
|
||
* @param {string} output 输出文件路径
|
||
* @param {object} options 选项
|
||
* @param {number} options.speed 速度倍数(0.25-4)
|
||
* @param {boolean} options.keepPitch 是否保持音调
|
||
* @param {boolean} options.overwrite 是否覆盖已存在的文件
|
||
*/
|
||
async function changeSpeed(input, output, options = {}) {
|
||
const { speed = 1, keepPitch = true, overwrite = false } = options;
|
||
|
||
const args = [];
|
||
if (overwrite) args.push("-y");
|
||
|
||
// 构建滤镜
|
||
const tempo = 1 / speed;
|
||
|
||
// 构建音频滤镜串
|
||
let audioFilter;
|
||
if (keepPitch) {
|
||
if (speed <= 0.5) {
|
||
// 0.25-0.5倍速
|
||
audioFilter = `asetrate=44100*${speed},aresample=44100,atempo=2,atempo=${
|
||
speed * 2
|
||
}`;
|
||
} else if (speed > 2) {
|
||
// 2-4倍速
|
||
audioFilter = `asetrate=44100*${speed},aresample=44100,atempo=0.5,atempo=${
|
||
speed * 0.5
|
||
}`;
|
||
} else {
|
||
// 0.5-2倍速
|
||
audioFilter = `asetrate=44100*${speed},aresample=44100,atempo=${speed}`;
|
||
}
|
||
} else {
|
||
// 不保持音调时直接调整速度
|
||
if (speed <= 0.5) {
|
||
audioFilter = `atempo=2,atempo=${speed * 2}`;
|
||
} else if (speed > 2) {
|
||
audioFilter = `atempo=0.5,atempo=${speed * 0.5}`;
|
||
} else {
|
||
audioFilter = `atempo=${speed}`;
|
||
}
|
||
}
|
||
|
||
args.push(
|
||
"-i",
|
||
input,
|
||
"-filter_complex",
|
||
`[0:v]setpts=${tempo}*PTS[v];[0:a]${audioFilter}[a]`,
|
||
"-map",
|
||
"[v]",
|
||
"-map",
|
||
"[a]",
|
||
output
|
||
);
|
||
|
||
await runFFmpeg(args, {
|
||
title: "视频调速",
|
||
text: "正在调整速度...",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 调整视频分辨率
|
||
* @param {string} input 输入文件路径
|
||
* @param {string} output 输出文件路径
|
||
* @param {object} options 选项
|
||
* @param {number} options.width 宽度
|
||
* @param {number} options.height 高度
|
||
* @param {boolean} options.keepAspectRatio 保持宽高比
|
||
* @param {boolean} options.overwrite 是否覆盖已存在的文件
|
||
*/
|
||
async function resizeVideo(input, output, options = {}) {
|
||
const {
|
||
width = -1,
|
||
height = -1,
|
||
keepAspectRatio = true,
|
||
overwrite = false,
|
||
} = options;
|
||
|
||
const args = [];
|
||
if (overwrite) args.push("-y");
|
||
|
||
// 构建缩放滤镜
|
||
const scale = keepAspectRatio
|
||
? `scale=${width}:${height}:force_original_aspect_ratio=decrease`
|
||
: `scale=${width}:${height}`;
|
||
|
||
args.push("-i", input, "-vf", scale, "-c:a", "copy", output);
|
||
|
||
await runFFmpeg(args, {
|
||
title: "调整分辨率",
|
||
text: "正在调整视频分辨率...",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 视频格式转换
|
||
* @param {string} input 输入文件路径
|
||
* @param {string} output 输出文件路径
|
||
* @param {object} options 选项
|
||
* @param {string} options.format 目标格式(mp4/webm/mkv/avi)
|
||
* @param {string} options.devicePreset 设备优化(general/mobile/tablet/tv)
|
||
* @param {string} options.resolution 分辨率(keep/3840:2160/2560:1440/1920:1080/1280:720/854:480)
|
||
* @param {number} options.fps 帧率
|
||
* @param {string} options.quality 视频质量(keep/high/medium/low)
|
||
* @param {string} options.preset 编码速度(keep/ultrafast/veryfast/fast/medium/slow/veryslow)
|
||
* @param {number} options.crf CRF值(0-51)
|
||
* @param {string} options.bitrateMode 码率控制模式(auto/cbr/vbr)
|
||
* @param {number} options.videoBitrate 视频码率(Kbps)
|
||
* @param {string} options.videoCodec 视频编码器
|
||
* @param {string} options.audioChannels 声道设置(keep/mono/stereo/5.1)
|
||
* @param {string} options.sampleRate 采样率(keep/44100/48000)
|
||
* @param {number} options.audioBitrate 音频码率(Kbps)
|
||
* @param {boolean} options.overwrite 是否覆盖已存在的文件
|
||
*/
|
||
async function convertFormat(input, output, options = {}) {
|
||
const {
|
||
format = "mp4",
|
||
devicePreset = "general",
|
||
resolution = "keep",
|
||
fps = null,
|
||
quality = "keep",
|
||
preset = "keep",
|
||
crf = null,
|
||
bitrateMode = "auto",
|
||
videoBitrate = null,
|
||
videoCodec = "copy",
|
||
audioChannels = "keep",
|
||
sampleRate = "keep",
|
||
audioBitrate = null,
|
||
overwrite = false,
|
||
} = options;
|
||
|
||
const args = [];
|
||
if (overwrite) args.push("-y");
|
||
|
||
args.push("-i", input);
|
||
|
||
// 根据设备预设设置参数
|
||
let targetWidth, targetHeight, targetVideoBitrate, targetAudioBitrate;
|
||
switch (devicePreset) {
|
||
case "mobile":
|
||
targetWidth = 1280;
|
||
targetHeight = 720;
|
||
targetVideoBitrate = 2000;
|
||
targetAudioBitrate = 128;
|
||
break;
|
||
case "tablet":
|
||
targetWidth = 1920;
|
||
targetHeight = 1080;
|
||
targetVideoBitrate = 4000;
|
||
targetAudioBitrate = 192;
|
||
break;
|
||
case "tv":
|
||
targetWidth = 3840;
|
||
targetHeight = 2160;
|
||
targetVideoBitrate = 8000;
|
||
targetAudioBitrate = 320;
|
||
break;
|
||
default: // general
|
||
targetWidth = null;
|
||
targetHeight = null;
|
||
targetVideoBitrate = null;
|
||
targetAudioBitrate = null;
|
||
}
|
||
|
||
// 分辨率设置
|
||
if (resolution !== "keep") {
|
||
const [width, height] = resolution.split(":");
|
||
targetWidth = parseInt(width);
|
||
targetHeight = parseInt(height);
|
||
}
|
||
|
||
if (targetWidth && targetHeight) {
|
||
args.push(
|
||
"-vf",
|
||
`scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease,pad=${targetWidth}:${targetHeight}:(ow-iw)/2:(oh-ih)/2`
|
||
);
|
||
}
|
||
|
||
// 帧率设置
|
||
if (fps) {
|
||
args.push("-r", fps.toString());
|
||
}
|
||
|
||
// 视频编码设置
|
||
let vcodec = videoCodec;
|
||
if (
|
||
videoCodec === "copy" &&
|
||
(devicePreset !== "general" || quality !== "keep")
|
||
) {
|
||
// 如果选择了设备优化或质量设置,但编码器为copy,则根据格式选择合适的编码器
|
||
switch (format) {
|
||
case "webm":
|
||
vcodec = "libvpx-vp9";
|
||
break;
|
||
case "mp4":
|
||
case "mkv":
|
||
vcodec = "libx264";
|
||
break;
|
||
case "avi":
|
||
vcodec = "libx264";
|
||
break;
|
||
}
|
||
}
|
||
args.push("-c:v", vcodec);
|
||
|
||
// 如果不是直接复制视频流,添加编码参数
|
||
if (vcodec !== "copy") {
|
||
// 质量设置
|
||
if (quality !== "keep") {
|
||
let targetCRF;
|
||
switch (quality) {
|
||
case "high":
|
||
targetCRF = 18;
|
||
break;
|
||
case "medium":
|
||
targetCRF = 23;
|
||
break;
|
||
case "low":
|
||
targetCRF = 28;
|
||
break;
|
||
}
|
||
args.push("-crf", targetCRF.toString());
|
||
} else if (crf !== null) {
|
||
args.push("-crf", crf.toString());
|
||
}
|
||
|
||
// 编码速度设置
|
||
if (preset !== "keep") {
|
||
args.push("-preset", preset);
|
||
}
|
||
|
||
// 码率控制
|
||
if (bitrateMode !== "auto") {
|
||
const vbitrate = videoBitrate || targetVideoBitrate || 4000;
|
||
if (bitrateMode === "cbr") {
|
||
args.push(
|
||
"-b:v",
|
||
`${vbitrate}k`,
|
||
"-maxrate",
|
||
`${vbitrate}k`,
|
||
"-minrate",
|
||
`${vbitrate}k`,
|
||
"-bufsize",
|
||
`${vbitrate * 2}k`
|
||
);
|
||
} else if (bitrateMode === "vbr") {
|
||
args.push(
|
||
"-b:v",
|
||
`${vbitrate}k`,
|
||
"-maxrate",
|
||
`${vbitrate * 2}k`,
|
||
"-bufsize",
|
||
`${vbitrate * 4}k`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 音频设置
|
||
if (audioChannels !== "keep") {
|
||
let ac;
|
||
switch (audioChannels) {
|
||
case "mono":
|
||
ac = 1;
|
||
break;
|
||
case "stereo":
|
||
ac = 2;
|
||
break;
|
||
case "5.1":
|
||
ac = 6;
|
||
break;
|
||
}
|
||
args.push("-ac", ac.toString());
|
||
}
|
||
|
||
if (sampleRate !== "keep") {
|
||
args.push("-ar", sampleRate.toString());
|
||
}
|
||
|
||
// 音频码率
|
||
const abitrate = audioBitrate || targetAudioBitrate;
|
||
if (abitrate) {
|
||
args.push("-b:a", `${abitrate}k`);
|
||
}
|
||
|
||
// 为特定格式添加额外参数
|
||
switch (format) {
|
||
case "webm":
|
||
if (vcodec !== "copy") {
|
||
args.push("-deadline", "good", "-cpu-used", "2");
|
||
}
|
||
break;
|
||
case "mp4":
|
||
args.push("-movflags", "+faststart");
|
||
break;
|
||
}
|
||
|
||
args.push(output);
|
||
|
||
await runFFmpeg(args, {
|
||
title: "格式转换",
|
||
text: "正在转换格式...",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 视频裁剪(画面)
|
||
* @param {string} input 输入文件路径
|
||
* @param {string} output 输出文件路径
|
||
* @param {object} options 选项
|
||
* @param {number} options.x 起始X坐标
|
||
* @param {number} options.y 起始Y坐标
|
||
* @param {number} options.width 裁剪宽度
|
||
* @param {number} options.height 裁剪高度
|
||
* @param {boolean} options.overwrite 是否覆盖已存在的文件
|
||
*/
|
||
async function cropVideo(input, output, options = {}) {
|
||
const { x = 0, y = 0, width = 0, height = 0, overwrite = false } = options;
|
||
|
||
const args = [];
|
||
if (overwrite) args.push("-y");
|
||
|
||
args.push(
|
||
"-i",
|
||
input,
|
||
"-vf",
|
||
`crop=${width}:${height}:${x}:${y}`,
|
||
"-c:a",
|
||
"copy",
|
||
output
|
||
);
|
||
|
||
await runFFmpeg(args, {
|
||
title: "视频裁剪",
|
||
text: "正在裁剪视频...",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 导出图片序列
|
||
* @param {string} input 输入文件路径
|
||
* @param {string} output 输出文件路径模式(例如: frame_%d.jpg)
|
||
* @param {object} options 选项
|
||
* @param {number} options.fps 每秒提取帧数
|
||
* @param {string} options.format 图片格式(jpg/png)
|
||
* @param {number} options.quality 图片质量(1-100)
|
||
* @param {boolean} options.overwrite 是否覆盖已存在的文件
|
||
*/
|
||
async function extractFrames(input, output, options = {}) {
|
||
const { fps = 1, format = "jpg", quality = 100, overwrite = false } = options;
|
||
|
||
const args = [];
|
||
if (overwrite) args.push("-y");
|
||
|
||
args.push("-i", input, "-vf", `fps=${fps}`, "-frame_pts", "1");
|
||
|
||
// 根据格式设置参数
|
||
if (format === "jpg") {
|
||
args.push("-q:v", Math.round(((100 - quality) / 100) * 31).toString());
|
||
} else if (format === "png") {
|
||
args.push(
|
||
"-compression_level",
|
||
Math.round(((100 - quality) / 100) * 9).toString()
|
||
);
|
||
}
|
||
|
||
args.push(output);
|
||
|
||
await runFFmpeg(args, {
|
||
title: "导出帧",
|
||
text: "正在导出图片序列...",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 生成缩略图
|
||
* @param {string} input 输入文件路径
|
||
* @param {string} output 输出文件路径
|
||
* @param {object} options 选项
|
||
* @param {number} options.time 截取时间点(秒)
|
||
* @param {number} options.width 缩略图宽度
|
||
* @param {string} options.format 图片格式(jpg/png)
|
||
* @param {number} options.quality 图片质量(1-100)
|
||
* @param {boolean} options.overwrite 是否覆盖已存在的文件
|
||
*/
|
||
async function generateThumbnail(input, output, options = {}) {
|
||
const {
|
||
time = 0,
|
||
width = 320,
|
||
format = "jpg",
|
||
quality = 90,
|
||
overwrite = false,
|
||
} = options;
|
||
|
||
const args = [];
|
||
if (overwrite) args.push("-y");
|
||
|
||
args.push(
|
||
"-ss",
|
||
time.toString(),
|
||
"-i",
|
||
input,
|
||
"-vframes",
|
||
"1",
|
||
"-vf",
|
||
`scale=${width}:-1`
|
||
);
|
||
|
||
// 根据格式设置参数
|
||
if (format === "jpg") {
|
||
args.push("-q:v", Math.round(((100 - quality) / 100) * 31).toString());
|
||
} else if (format === "png") {
|
||
args.push(
|
||
"-compression_level",
|
||
Math.round(((100 - quality) / 100) * 9).toString()
|
||
);
|
||
}
|
||
|
||
args.push(output);
|
||
|
||
await runFFmpeg(args, {
|
||
title: "生成缩略图",
|
||
text: "正在生成缩略图...",
|
||
});
|
||
}
|
||
|
||
module.exports = {
|
||
runFFmpeg,
|
||
compressVideo,
|
||
convertToGif,
|
||
extractAudio,
|
||
recordScreen,
|
||
cutVideo,
|
||
rotateVideo,
|
||
addWatermark,
|
||
mergeVideos,
|
||
changeSpeed,
|
||
resizeVideo,
|
||
convertFormat,
|
||
cropVideo,
|
||
extractFrames,
|
||
generateThumbnail,
|
||
};
|