970 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 运行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,
};