- 在命令分类中添加视频操作命令,支持视频压缩、视频转GIF、音频提取、视频剪切、视频旋转/翻转、添加水印、视频合并、视频调速、视频分辨率调整、视频格式转换、视频裁剪、导出图片序列、生成缩略图

This commit is contained in:
fofolee 2025-01-28 01:13:46 +08:00
parent 31a543d0c5
commit 70e01a53d8
11 changed files with 2590 additions and 7 deletions

View File

@ -322,7 +322,7 @@ const showProcessBar = async (options = {}) => {
throw new Error("onPause 和 onResume 必须同时配置"); throw new Error("onPause 和 onResume 必须同时配置");
} }
const windowWidth = 300; const windowWidth = 350;
const windowHeight = 60; const windowHeight = 60;
// 计算窗口位置 // 计算窗口位置

View File

@ -606,7 +606,16 @@ textarea:focus {
.process-text { .process-text {
font-size: 13px; font-size: 13px;
margin-bottom: 8px; margin-bottom: 8px;
padding-right: 20px; white-space: nowrap;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
width: calc(100% - 30px);
box-sizing: border-box;
}
.process-text::-webkit-scrollbar {
display: none;
} }
.process-bar { .process-bar {
@ -633,6 +642,21 @@ textarea:focus {
display: flex; display: flex;
gap: 8px; gap: 8px;
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
z-index: 1;
}
/* 添加按钮区域的渐变遮罩 */
.process-buttons::before {
content: "";
position: absolute;
right: -8px;
/* 对应#process的padding-right */
top: -6px;
/* 对应#process的padding-top */
width: 80px;
height: 30px;
background: linear-gradient(to right, transparent, var(--button-bg) 60%);
pointer-events: none;
} }
/* 进度条关闭按钮 */ /* 进度条关闭按钮 */

View File

@ -12,6 +12,7 @@ const quickcomposer = {
macos: require("./quickcomposer/macos"), macos: require("./quickcomposer/macos"),
status: require("./quickcomposer/status"), status: require("./quickcomposer/status"),
browser: require("./quickcomposer/browser"), browser: require("./quickcomposer/browser"),
video: require("./quickcomposer/video"),
}; };
module.exports = quickcomposer; module.exports = quickcomposer;

View File

@ -0,0 +1,969 @@
/**
* 运行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,
};

View File

@ -0,0 +1,5 @@
const ffmpeg = require("./ffmpeg");
module.exports = {
...ffmpeg,
};

View File

@ -179,15 +179,22 @@ export default defineComponent({
}, },
getAllInputValues(argvs) { getAllInputValues(argvs) {
const flatArgvs = []; const flatArgvs = [];
if (!argvs) return flatArgvs;
argvs.forEach((item) => { argvs.forEach((item) => {
if (!item) return;
if (isVarInputVal(item) && item.value) { if (isVarInputVal(item) && item.value) {
flatArgvs.push(stringifyVarInputVal(item)); flatArgvs.push(stringifyVarInputVal(item));
} else if (typeof item === "number") { } else if (typeof item === "number") {
flatArgvs.push(item.toString()); flatArgvs.push(item.toString());
} else if (Array.isArray(item)) { } else if (Array.isArray(item)) {
flatArgvs.push(...this.getAllInputValues(item)); flatArgvs.push(...this.getAllInputValues(item));
} else if (typeof item === "object") { } else if (typeof item === "object" && item !== null) {
flatArgvs.push(...this.getAllInputValues(Object.values(item))); const values = Object.values(item);
if (values.length > 0) {
flatArgvs.push(...this.getAllInputValues(values));
}
} }
}); });
return flatArgvs; return flatArgvs;
@ -226,7 +233,7 @@ export default defineComponent({
}, },
mounted() { mounted() {
const argvs = this.modelValue.argvs || this.defaultArgvs; const argvs = this.modelValue.argvs || this.defaultArgvs;
if (!this.modelValue.code) { if (!this.modelValue.code && Array.isArray(argvs)) {
this.updateModelValue(this.funcName, argvs); this.updateModelValue(this.funcName, argvs);
} }
}, },

View File

@ -0,0 +1,181 @@
<template>
<BorderLabel
:label="label"
:icon="icon"
:model-value="false"
class="time-input"
>
<div class="row q-col-gutter-sm items-center">
<!-- -->
<div class="col-4">
<q-input
v-model="hours"
dense
borderless
:placeholder="'00'"
@update:model-value="updateTime"
@blur="formatInput('hours')"
maxlength="2"
class="time-field"
>
<template v-slot:append>
<div class="text-caption"></div>
</template>
</q-input>
</div>
<!-- -->
<div class="col-4">
<q-input
v-model="minutes"
dense
borderless
:placeholder="'00'"
@update:model-value="updateTime"
@blur="formatInput('minutes')"
maxlength="2"
class="time-field"
>
<template v-slot:append>
<div class="text-caption"></div>
</template>
</q-input>
</div>
<!-- -->
<div class="col-4">
<q-input
v-model="seconds"
dense
borderless
:placeholder="'00'"
@update:model-value="updateTime"
@blur="formatInput('seconds')"
maxlength="2"
class="time-field"
>
<template v-slot:append>
<div class="text-caption"></div>
</template>
</q-input>
</div>
</div>
</BorderLabel>
</template>
<script>
import BorderLabel from "./BorderLabel.vue";
export default {
name: "TimeInput",
components: {
BorderLabel,
},
props: {
modelValue: {
type: String,
default: "00:00:00",
},
label: {
type: String,
default: "",
},
icon: {
type: String,
default: "",
},
iconClickable: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: "00:00:00",
},
},
data() {
return {
hours: "00",
minutes: "00",
seconds: "00",
};
},
watch: {
modelValue: {
immediate: true,
handler(newVal) {
if (newVal) {
const parts = newVal.split(":");
if (parts.length === 3) {
this.hours = parts[0];
this.minutes = parts[1];
this.seconds = parts[2];
}
}
},
},
},
methods: {
//
updateTime() {
const formattedTime = `${this.hours}:${this.minutes}:${this.seconds}`;
this.$emit("update:model-value", formattedTime);
},
//
formatInput(field) {
const value = this[field];
let num = parseInt(value) || 0;
//
switch (field) {
case "hours":
num = Math.min(Math.max(num, 0), 99);
break;
case "minutes":
case "seconds":
num = Math.min(Math.max(num, 0), 59);
break;
}
//
this[field] = num.toString().padStart(2, "0");
this.updateTime();
},
},
};
</script>
<style scoped>
.time-input {
width: 100%;
height: 36px;
}
.time-input :deep(.content) {
padding: 0 8px;
}
.time-field {
font-size: 13px;
}
.time-input :deep(.border-label) {
margin-top: 0;
}
/* 文本居中显示 */
.time-input :deep(.q-field__native) {
text-align: center;
padding: 0;
height: 34px;
}
.time-input :deep(.q-field__control) {
padding: 0 4px;
height: 34px;
}
/* 调整单位标签样式 */
.time-input :deep(.q-field__append) {
padding-left: 0;
height: 34px;
}
</style>

View File

@ -22,6 +22,7 @@ import ButtonGroup from "components/composer/common/ButtonGroup.vue";
import ControlInput from "components/composer/common/ControlInput.vue"; import ControlInput from "components/composer/common/ControlInput.vue";
import CheckGroup from "components/composer/common/CheckGroup.vue"; import CheckGroup from "components/composer/common/CheckGroup.vue";
import CheckButton from "components/composer/common/CheckButton.vue"; import CheckButton from "components/composer/common/CheckButton.vue";
import TimeInput from "components/composer/common/TimeInput.vue";
import { QInput, QSelect, QToggle, QCheckbox } from "quasar"; import { QInput, QSelect, QToggle, QCheckbox } from "quasar";
const CodeEditor = defineAsyncComponent(() => const CodeEditor = defineAsyncComponent(() =>
import("components/composer/common/CodeEditor.vue") import("components/composer/common/CodeEditor.vue")
@ -42,6 +43,7 @@ export default defineComponent({
QInput, QInput,
QSelect, QSelect,
QCheckbox, QCheckbox,
TimeInput,
CodeEditor, CodeEditor,
}, },
props: { props: {

View File

@ -19,6 +19,7 @@ import { statusCommands } from "./statusCommands";
import { macosCommands } from "./macosCommands"; import { macosCommands } from "./macosCommands";
import { scriptCommands } from "./scriptCommands"; import { scriptCommands } from "./scriptCommands";
import { browserCommands } from "./browserCommands"; import { browserCommands } from "./browserCommands";
import { videoCommands } from "./videoCommands";
const platformCommands = { const platformCommands = {
win32: [windowsCommands], win32: [windowsCommands],
@ -32,6 +33,7 @@ export const commandCategories = [
systemCommands, systemCommands,
audioCommands, audioCommands,
imageCommands, imageCommands,
...(utools.runFFmpeg ? [videoCommands] : []),
utoolsCommands, utoolsCommands,
...platformCommands[window.processPlatform], ...platformCommands[window.processPlatform],
browserCommands, browserCommands,

File diff suppressed because it is too large Load Diff

View File

@ -131,7 +131,5 @@ export function generateCode(flow) {
const finalCode = code.join("\n"); const finalCode = code.join("\n");
console.log(finalCode);
return finalCode; return finalCode;
} }