From 70e01a53d8266b93a7057e06e7e779fca0cb2334 Mon Sep 17 00:00:00 2001 From: fofolee Date: Tue, 28 Jan 2025 01:13:46 +0800 Subject: [PATCH] =?UTF-8?q?-=20=E5=9C=A8=E5=91=BD=E4=BB=A4=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E4=B8=AD=E6=B7=BB=E5=8A=A0=E8=A7=86=E9=A2=91=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E5=91=BD=E4=BB=A4=EF=BC=8C=E6=94=AF=E6=8C=81=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E5=8E=8B=E7=BC=A9=E3=80=81=E8=A7=86=E9=A2=91=E8=BD=AC?= =?UTF-8?q?GIF=E3=80=81=E9=9F=B3=E9=A2=91=E6=8F=90=E5=8F=96=E3=80=81?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E5=89=AA=E5=88=87=E3=80=81=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E6=97=8B=E8=BD=AC/=E7=BF=BB=E8=BD=AC=E3=80=81=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=B0=B4=E5=8D=B0=E3=80=81=E8=A7=86=E9=A2=91=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E3=80=81=E8=A7=86=E9=A2=91=E8=B0=83=E9=80=9F=E3=80=81?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E5=88=86=E8=BE=A8=E7=8E=87=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E3=80=81=E8=A7=86=E9=A2=91=E6=A0=BC=E5=BC=8F=E8=BD=AC=E6=8D=A2?= =?UTF-8?q?=E3=80=81=E8=A7=86=E9=A2=91=E8=A3=81=E5=89=AA=E3=80=81=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=9B=BE=E7=89=87=E5=BA=8F=E5=88=97=E3=80=81=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=BC=A9=E7=95=A5=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugin/lib/dialog/service.js | 2 +- plugin/lib/dialog/style.css | 26 +- plugin/lib/quickcomposer.js | 1 + plugin/lib/quickcomposer/video/ffmpeg.js | 969 ++++++++++++ plugin/lib/quickcomposer/video/index.js | 5 + src/components/composer/MultiParams.vue | 13 +- src/components/composer/common/TimeInput.vue | 181 +++ .../composer/param/ParamImporter.vue | 2 + src/js/composer/commands/index.js | 2 + src/js/composer/commands/videoCommands.js | 1394 +++++++++++++++++ src/js/composer/generateCode.js | 2 - 11 files changed, 2590 insertions(+), 7 deletions(-) create mode 100644 plugin/lib/quickcomposer/video/ffmpeg.js create mode 100644 plugin/lib/quickcomposer/video/index.js create mode 100644 src/components/composer/common/TimeInput.vue create mode 100644 src/js/composer/commands/videoCommands.js diff --git a/plugin/lib/dialog/service.js b/plugin/lib/dialog/service.js index 746f467..3753801 100644 --- a/plugin/lib/dialog/service.js +++ b/plugin/lib/dialog/service.js @@ -322,7 +322,7 @@ const showProcessBar = async (options = {}) => { throw new Error("onPause 和 onResume 必须同时配置"); } - const windowWidth = 300; + const windowWidth = 350; const windowHeight = 60; // 计算窗口位置 diff --git a/plugin/lib/dialog/style.css b/plugin/lib/dialog/style.css index f92567c..09f1e72 100644 --- a/plugin/lib/dialog/style.css +++ b/plugin/lib/dialog/style.css @@ -606,7 +606,16 @@ textarea:focus { .process-text { font-size: 13px; 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 { @@ -633,6 +642,21 @@ textarea:focus { display: flex; gap: 8px; -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; } /* 进度条关闭按钮 */ diff --git a/plugin/lib/quickcomposer.js b/plugin/lib/quickcomposer.js index 2052497..8c05e61 100644 --- a/plugin/lib/quickcomposer.js +++ b/plugin/lib/quickcomposer.js @@ -12,6 +12,7 @@ const quickcomposer = { macos: require("./quickcomposer/macos"), status: require("./quickcomposer/status"), browser: require("./quickcomposer/browser"), + video: require("./quickcomposer/video"), }; module.exports = quickcomposer; diff --git a/plugin/lib/quickcomposer/video/ffmpeg.js b/plugin/lib/quickcomposer/video/ffmpeg.js new file mode 100644 index 0000000..6f048d4 --- /dev/null +++ b/plugin/lib/quickcomposer/video/ffmpeg.js @@ -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, +}; diff --git a/plugin/lib/quickcomposer/video/index.js b/plugin/lib/quickcomposer/video/index.js new file mode 100644 index 0000000..9f52dd2 --- /dev/null +++ b/plugin/lib/quickcomposer/video/index.js @@ -0,0 +1,5 @@ +const ffmpeg = require("./ffmpeg"); + +module.exports = { + ...ffmpeg, +}; diff --git a/src/components/composer/MultiParams.vue b/src/components/composer/MultiParams.vue index 0d13dcc..2730a41 100644 --- a/src/components/composer/MultiParams.vue +++ b/src/components/composer/MultiParams.vue @@ -179,15 +179,22 @@ export default defineComponent({ }, getAllInputValues(argvs) { const flatArgvs = []; + if (!argvs) return flatArgvs; + argvs.forEach((item) => { + if (!item) return; + if (isVarInputVal(item) && item.value) { flatArgvs.push(stringifyVarInputVal(item)); } else if (typeof item === "number") { flatArgvs.push(item.toString()); } else if (Array.isArray(item)) { flatArgvs.push(...this.getAllInputValues(item)); - } else if (typeof item === "object") { - flatArgvs.push(...this.getAllInputValues(Object.values(item))); + } else if (typeof item === "object" && item !== null) { + const values = Object.values(item); + if (values.length > 0) { + flatArgvs.push(...this.getAllInputValues(values)); + } } }); return flatArgvs; @@ -226,7 +233,7 @@ export default defineComponent({ }, mounted() { const argvs = this.modelValue.argvs || this.defaultArgvs; - if (!this.modelValue.code) { + if (!this.modelValue.code && Array.isArray(argvs)) { this.updateModelValue(this.funcName, argvs); } }, diff --git a/src/components/composer/common/TimeInput.vue b/src/components/composer/common/TimeInput.vue new file mode 100644 index 0000000..594f161 --- /dev/null +++ b/src/components/composer/common/TimeInput.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/src/components/composer/param/ParamImporter.vue b/src/components/composer/param/ParamImporter.vue index 9723a10..52a7d0b 100644 --- a/src/components/composer/param/ParamImporter.vue +++ b/src/components/composer/param/ParamImporter.vue @@ -22,6 +22,7 @@ import ButtonGroup from "components/composer/common/ButtonGroup.vue"; import ControlInput from "components/composer/common/ControlInput.vue"; import CheckGroup from "components/composer/common/CheckGroup.vue"; import CheckButton from "components/composer/common/CheckButton.vue"; +import TimeInput from "components/composer/common/TimeInput.vue"; import { QInput, QSelect, QToggle, QCheckbox } from "quasar"; const CodeEditor = defineAsyncComponent(() => import("components/composer/common/CodeEditor.vue") @@ -42,6 +43,7 @@ export default defineComponent({ QInput, QSelect, QCheckbox, + TimeInput, CodeEditor, }, props: { diff --git a/src/js/composer/commands/index.js b/src/js/composer/commands/index.js index 961f9d2..f8dcea8 100644 --- a/src/js/composer/commands/index.js +++ b/src/js/composer/commands/index.js @@ -19,6 +19,7 @@ import { statusCommands } from "./statusCommands"; import { macosCommands } from "./macosCommands"; import { scriptCommands } from "./scriptCommands"; import { browserCommands } from "./browserCommands"; +import { videoCommands } from "./videoCommands"; const platformCommands = { win32: [windowsCommands], @@ -32,6 +33,7 @@ export const commandCategories = [ systemCommands, audioCommands, imageCommands, + ...(utools.runFFmpeg ? [videoCommands] : []), utoolsCommands, ...platformCommands[window.processPlatform], browserCommands, diff --git a/src/js/composer/commands/videoCommands.js b/src/js/composer/commands/videoCommands.js new file mode 100644 index 0000000..b0e0db8 --- /dev/null +++ b/src/js/composer/commands/videoCommands.js @@ -0,0 +1,1394 @@ +import { newVarInputVal } from "js/composer/varInputValManager"; + +const getDesktopPath = (fileName) => { + return window.joinPath(utools.getPath("desktop"), fileName); +}; + +// 视频编码器选项 +const VIDEO_ENCODERS = [ + { label: "H.264", value: "libx264" }, + { label: "H.265", value: "libx265" }, + { label: "VP8", value: "libvpx" }, + { label: "VP9", value: "libvpx-vp9" }, +]; + +// 音频编码器选项 +const AUDIO_ENCODERS = [ + { label: "AAC", value: "aac" }, + { label: "MP3", value: "libmp3lame" }, + { label: "Opus", value: "libopus" }, + { label: "Vorbis", value: "libvorbis" }, +]; + +// 图片格式选项 +const IMAGE_FORMATS = [ + { label: "JPG", value: "jpg" }, + { label: "PNG", value: "png" }, +]; + +// 编码器预设选项 +const ENCODER_PRESETS = [ + { label: "保持不变", value: "keep" }, + { label: "超快", value: "ultrafast" }, + { label: "非常快", value: "veryfast" }, + { label: "快速", value: "fast" }, + { label: "中等", value: "medium" }, + { label: "慢速", value: "slow" }, + { label: "非常慢", value: "veryslow" }, +]; + +// 视频质量选项 +const VIDEO_QUALITY = [ + { label: "保持不变", value: "keep" }, + { label: "高质量", value: "high" }, + { label: "中等质量", value: "medium" }, + { label: "低质量", value: "low" }, +]; + +// 分辨率选项 +const RESOLUTIONS = [ + { label: "保持不变", value: "keep" }, + { label: "4K(3840x2160)", value: "3840:2160" }, + { label: "2K(2560x1440)", value: "2560:1440" }, + { label: "1080P(1920x1080)", value: "1920:1080" }, + { label: "720P(1280x720)", value: "1280:720" }, + { label: "480P(854x480)", value: "854:480" }, +]; + +// 视频格式选项 +const VIDEO_FORMATS = [ + { label: "MP4 (通用格式)", value: "mp4" }, + { label: "WebM (网页视频)", value: "webm" }, + { label: "MKV (高清视频)", value: "mkv" }, + { label: "AVI (传统格式)", value: "avi" }, +]; + +// 设备优化选项 +const DEVICE_PRESETS = [ + { label: "通用", value: "general" }, + { label: "手机", value: "mobile" }, + { label: "平板", value: "tablet" }, + { label: "电视", value: "tv" }, +]; + +// 码率控制模式 +const BITRATE_MODES = [ + { label: "自动", value: "auto" }, + { label: "固定码率", value: "cbr" }, + { label: "可变码率", value: "vbr" }, +]; + +// 声道选项 +const AUDIO_CHANNELS = [ + { label: "保持原有", value: "keep" }, + { label: "单声道", value: "mono" }, + { label: "立体声", value: "stereo" }, + { label: "5.1环绕", value: "5.1" }, +]; + +// 采样率选项 +const SAMPLE_RATES = [ + { label: "保持原有", value: "keep" }, + { label: "44.1kHz", value: "44100" }, + { label: "48kHz", value: "48000" }, +]; + +export const videoCommands = { + label: "视频操作", + icon: "video_library", + defaultOpened: false, + commands: [ + { + value: "quickcomposer.video.convertFormat", + label: "格式转换", + icon: "transform", + asyncMode: "await", + config: [ + { + label: "输入文件", + component: "VariableInput", + icon: "video_file", + width: 12, + options: { + dialog: { + type: "open", + options: { + title: "选择视频文件", + filters: [ + { + name: "视频文件", + extensions: [ + "mp4", + "avi", + "mkv", + "mov", + "wmv", + "flv", + "webm", + ], + }, + ], + }, + }, + }, + }, + { + label: "输出文件", + component: "VariableInput", + icon: "save", + width: 12, + options: { + dialog: { + type: "save", + options: { + title: "保存视频", + filters: [ + { + name: "MP4视频", + extensions: ["mp4"], + }, + { + name: "WebM视频", + extensions: ["webm"], + }, + { + name: "MKV视频", + extensions: ["mkv"], + }, + { + name: "AVI视频", + extensions: ["avi"], + }, + ], + defaultPath: "output.mp4", + }, + }, + }, + defaultValue: newVarInputVal("str", getDesktopPath("output.mp4")), + }, + { + component: "OptionEditor", + width: 12, + options: { + format: { + label: "目标格式", + component: "QSelect", + width: 3, + options: VIDEO_FORMATS, + }, + devicePreset: { + label: "设备优化", + component: "QSelect", + width: 3, + options: DEVICE_PRESETS, + }, + resolution: { + label: "分辨率", + component: "QSelect", + width: 3, + options: RESOLUTIONS, + }, + fps: { + label: "帧率", + component: "NumberInput", + width: 3, + min: 1, + max: 60, + placeholder: "保持原有", + }, + quality: { + label: "视频质量", + component: "QSelect", + width: 4, + options: VIDEO_QUALITY, + }, + preset: { + label: "编码速度", + component: "QSelect", + width: 4, + options: ENCODER_PRESETS, + }, + crf: { + label: "CRF(0-51)", + component: "NumberInput", + width: 4, + min: 0, + max: 51, + placeholder: "自动", + }, + bitrateMode: { + label: "码率控制", + component: "QSelect", + width: 4, + options: BITRATE_MODES, + }, + videoBitrate: { + label: "视频码率(Kbps)", + component: "NumberInput", + width: 4, + min: 100, + placeholder: "自动", + }, + videoCodec: { + label: "视频编码器", + component: "QSelect", + width: 4, + options: [ + { label: "自动选择", value: "copy" }, + ...VIDEO_ENCODERS, + ], + }, + audioChannels: { + label: "声道", + component: "QSelect", + width: 4, + options: AUDIO_CHANNELS, + }, + sampleRate: { + label: "采样率", + component: "QSelect", + width: 4, + options: SAMPLE_RATES, + }, + audioBitrate: { + label: "音频码率(Kbps)", + component: "NumberInput", + width: 4, + min: 32, + max: 320, + placeholder: "自动", + }, + overwrite: { + label: "覆盖已存在目标文件", + component: "CheckButton", + width: 12, + }, + }, + defaultValue: { + 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: true, + }, + }, + ], + }, + { + value: "quickcomposer.video.compressVideo", + label: "视频压缩", + icon: "compress", + asyncMode: "await", + config: [ + { + label: "输入文件", + component: "VariableInput", + icon: "video_file", + width: 12, + options: { + dialog: { + type: "open", + options: { + title: "选择视频文件", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + properties: ["openFile", "showHiddenFiles"], + }, + }, + }, + }, + { + label: "输出文件", + component: "VariableInput", + icon: "save", + width: 12, + options: { + dialog: { + type: "save", + options: { + title: "保存视频", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + defaultPath: "output.mp4", + }, + }, + }, + defaultValue: newVarInputVal("str", getDesktopPath("output.mp4")), + }, + { + component: "OptionEditor", + width: 12, + options: { + encoder: { + label: "视频编码器", + component: "QSelect", + width: 3, + options: VIDEO_ENCODERS, + }, + preset: { + label: "压缩预设", + component: "QSelect", + width: 3, + options: ENCODER_PRESETS, + }, + crf: { + label: "质量(0-51)", + component: "NumberInput", + width: 3, + min: 0, + max: 51, + }, + resolution: { + label: "分辨率", + component: "QSelect", + width: 3, + options: RESOLUTIONS, + }, + overwrite: { + label: "覆盖已存在目标文件", + component: "CheckButton", + width: 12, + }, + }, + defaultValue: { + encoder: "libx264", + preset: "medium", + crf: 23, + resolution: "keep", + overwrite: true, + }, + }, + ], + }, + { + value: "quickcomposer.video.convertToGif", + label: "视频转GIF", + icon: "gif", + asyncMode: "await", + config: [ + { + label: "输入文件", + component: "VariableInput", + icon: "video_file", + width: 12, + options: { + dialog: { + type: "open", + options: { + title: "选择视频文件", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + }, + }, + }, + }, + { + label: "输出文件", + component: "VariableInput", + icon: "save", + width: 12, + options: { + dialog: { + type: "save", + options: { + title: "保存GIF", + filters: [ + { + name: "GIF图片", + extensions: ["gif"], + }, + ], + defaultPath: "output.gif", + }, + }, + }, + defaultValue: newVarInputVal("str", getDesktopPath("output.gif")), + }, + { + component: "OptionEditor", + width: 12, + options: { + fps: { + label: "帧率", + component: "NumberInput", + icon: "speed", + width: 4, + min: 1, + max: 60, + }, + width: { + label: "宽度", + component: "NumberInput", + icon: "width", + width: 4, + min: 1, + }, + overwrite: { + label: "覆盖已存在目标文件", + component: "CheckButton", + width: 4, + }, + }, + defaultValue: { + fps: 15, + width: 480, + overwrite: true, + }, + }, + ], + }, + { + value: "quickcomposer.video.extractAudio", + label: "提取音频", + icon: "audio_file", + asyncMode: "await", + config: [ + { + label: "输入文件", + component: "VariableInput", + icon: "video_file", + width: 12, + options: { + dialog: { + type: "open", + options: { + title: "选择视频文件", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + }, + }, + }, + }, + { + label: "输出文件", + component: "VariableInput", + icon: "save", + width: 12, + options: { + dialog: { + type: "save", + options: { + title: "保存音频", + filters: [ + { + name: "音频文件", + extensions: ["mp3", "aac", "wav", "m4a"], + }, + ], + defaultPath: "output.mp3", + }, + }, + }, + defaultValue: newVarInputVal("str", getDesktopPath("output.mp3")), + }, + { + component: "OptionEditor", + width: 12, + options: { + quality: { + label: "音频质量", + component: "NumberInput", + icon: "high_quality", + width: 6, + min: 0, + max: 9, + placeholder: "0-9,0为最高质量", + }, + overwrite: { + label: "覆盖已存在目标文件", + component: "CheckButton", + width: 6, + }, + }, + defaultValue: { + quality: 0, + overwrite: true, + }, + }, + ], + }, + // utools接口目前好像有问题,无法结束录制,暂时注释 + // { + // value: "quickcomposer.video.recordScreen", + // label: "录制屏幕", + // icon: "screen_record", + // asyncMode: "await", + // config: [ + // { + // label: "输出文件", + // component: "VariableInput", + // icon: "save", + // width: 12, + // options: { + // dialog: { + // type: "save", + // options: { + // title: "保存录屏", + // filters: [ + // { + // name: "视频文件", + // extensions: ["mp4"], + // }, + // ], + // defaultPath: "output.mp4", + // }, + // }, + // }, + // defaultValue: newVarInputVal("str", getDesktopPath("output.mp4")), + // }, + // { + // component: "OptionEditor", + // width: 12, + // options: { + // fps: { + // label: "帧率", + // component: "NumberInput", + // icon: "speed", + // width: 6, + // min: 1, + // max: 60, + // }, + // overwrite: { + // label: "覆盖已存在目标文件", + // component: "CheckButton", + // width: 6, + // }, + // }, + // defaultValue: { + // fps: 30, + // overwrite: true, + // }, + // }, + // ], + // }, + { + value: "quickcomposer.video.cutVideo", + label: "截取片段", + icon: "content_cut", + asyncMode: "await", + config: [ + { + label: "输入文件", + component: "VariableInput", + icon: "video_file", + width: 12, + options: { + dialog: { + type: "open", + options: { + title: "选择视频文件", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + }, + }, + }, + }, + { + label: "输出文件", + component: "VariableInput", + icon: "save", + width: 12, + options: { + dialog: { + type: "save", + options: { + title: "保存视频", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + defaultPath: "output.mp4", + }, + }, + }, + defaultValue: newVarInputVal("str", getDesktopPath("output.mp4")), + }, + { + component: "OptionEditor", + width: 12, + options: { + start: { + label: "开始时间", + component: "TimeInput", + icon: "schedule", + width: 4, + placeholder: "00:00:00", + }, + duration: { + label: "持续时长", + component: "TimeInput", + icon: "timer", + width: 4, + placeholder: "00:00:00", + }, + overwrite: { + label: "覆盖已存在目标文件", + component: "CheckButton", + width: 4, + }, + }, + defaultValue: { + start: "00:00:00", + duration: "00:00:10", + overwrite: true, + }, + }, + ], + }, + { + value: "quickcomposer.video.rotateVideo", + label: "旋转/翻转", + icon: "rotate_90_degrees_ccw", + asyncMode: "await", + config: [ + { + label: "输入文件", + component: "VariableInput", + icon: "video_file", + width: 12, + options: { + dialog: { + type: "open", + options: { + title: "选择视频文件", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + }, + }, + }, + }, + { + label: "输出文件", + component: "VariableInput", + icon: "save", + width: 12, + options: { + dialog: { + type: "save", + options: { + title: "保存视频", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + defaultPath: "output.mp4", + }, + }, + }, + defaultValue: newVarInputVal("str", getDesktopPath("output.mp4")), + }, + { + component: "OptionEditor", + width: 12, + options: { + rotate: { + label: "旋转角度", + component: "QSelect", + width: 3, + options: [ + { label: "不旋转", value: 0 }, + { label: "90度", value: 90 }, + { label: "180度", value: 180 }, + { label: "270度", value: 270 }, + ], + }, + flipH: { + label: "水平翻转", + component: "CheckButton", + width: 3, + }, + flipV: { + label: "垂直翻转", + component: "CheckButton", + width: 3, + }, + overwrite: { + label: "覆盖已存在目标文件", + component: "CheckButton", + width: 3, + }, + }, + defaultValue: { + rotate: 0, + flipH: false, + flipV: false, + overwrite: true, + }, + }, + ], + }, + { + value: "quickcomposer.video.addWatermark", + label: "添加水印", + icon: "branding_watermark", + asyncMode: "await", + config: [ + { + label: "输入文件", + component: "VariableInput", + icon: "video_file", + width: 12, + options: { + dialog: { + type: "open", + options: { + title: "选择视频文件", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + }, + }, + }, + }, + { + label: "水印图片", + component: "VariableInput", + icon: "image", + width: 12, + options: { + dialog: { + type: "open", + options: { + title: "选择水印图片", + filters: [ + { + name: "图片文件", + extensions: ["png", "jpg", "jpeg", "gif"], + }, + ], + }, + }, + }, + }, + { + label: "输出文件", + component: "VariableInput", + icon: "save", + width: 12, + options: { + dialog: { + type: "save", + options: { + title: "保存视频", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + defaultPath: "output.mp4", + }, + }, + }, + defaultValue: newVarInputVal("str", getDesktopPath("output.mp4")), + }, + { + component: "OptionEditor", + width: 12, + options: { + position: { + label: "位置", + component: "QSelect", + width: 3, + options: [ + { label: "左上", value: "topleft" }, + { label: "右上", value: "topright" }, + { label: "左下", value: "bottomleft" }, + { label: "右下", value: "bottomright" }, + { label: "居中", value: "center" }, + ], + }, + padding: { + label: "边距", + component: "NumberInput", + width: 3, + min: 0, + }, + scale: { + label: "缩放比例", + component: "NumberInput", + width: 3, + min: 0.1, + max: 1, + step: 0.1, + }, + overwrite: { + label: "覆盖已存在目标文件", + component: "CheckButton", + width: 3, + }, + }, + defaultValue: { + position: "bottomright", + padding: 10, + scale: 0.1, + overwrite: true, + }, + }, + ], + }, + { + value: "quickcomposer.video.mergeVideos", + label: "合并视频", + icon: "merge", + description: "将多个视频文件合并为一个,分辨率会统一为第一个视频的分辨率", + asyncMode: "await", + config: [ + { + label: "输入文件", + component: "VariableInput", + icon: "video_file", + width: 12, + placeholder: "合并的视频顺序依据选择的视频顺序", + options: { + dialog: { + type: "open", + options: { + title: "选择视频文件", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + properties: ["openFile", "multiSelections"], + }, + }, + }, + }, + { + label: "输出文件", + component: "VariableInput", + icon: "save", + width: 12, + options: { + dialog: { + type: "save", + options: { + title: "保存视频", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + defaultPath: "output.mp4", + }, + }, + }, + defaultValue: newVarInputVal("str", getDesktopPath("output.mp4")), + }, + { + component: "OptionEditor", + width: 12, + options: { + overwrite: { + label: "覆盖已存在目标文件", + component: "CheckButton", + width: 12, + }, + }, + defaultValue: { + overwrite: true, + }, + }, + ], + }, + { + value: "quickcomposer.video.changeSpeed", + label: "视频调速", + icon: "speed", + description: "调整视频播放速度,调速区间0.25-4", + asyncMode: "await", + config: [ + { + label: "输入文件", + component: "VariableInput", + icon: "video_file", + width: 12, + options: { + dialog: { + type: "open", + options: { + title: "选择视频文件", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + }, + }, + }, + }, + { + label: "输出文件", + component: "VariableInput", + icon: "save", + width: 12, + options: { + dialog: { + type: "save", + options: { + title: "保存视频", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + defaultPath: "output.mp4", + }, + }, + }, + defaultValue: newVarInputVal("str", getDesktopPath("output.mp4")), + }, + { + component: "OptionEditor", + width: 12, + options: { + speed: { + label: "速度倍数", + component: "NumberInput", + width: 4, + min: 0.25, + max: 4, + step: 0.25, + placeholder: "0.25-4", + }, + keepPitch: { + label: "保持音调", + component: "CheckButton", + width: 4, + }, + overwrite: { + label: "覆盖已存在目标文件", + component: "CheckButton", + width: 4, + }, + }, + defaultValue: { + speed: 1, + keepPitch: true, + overwrite: true, + }, + }, + ], + }, + { + value: "quickcomposer.video.resizeVideo", + label: "调整分辨率", + icon: "aspect_ratio", + asyncMode: "await", + config: [ + { + label: "输入文件", + component: "VariableInput", + icon: "video_file", + width: 12, + options: { + dialog: { + type: "open", + options: { + title: "选择视频文件", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + }, + }, + }, + }, + { + label: "输出文件", + component: "VariableInput", + icon: "save", + width: 12, + options: { + dialog: { + type: "save", + options: { + title: "保存视频", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + defaultPath: "output.mp4", + }, + }, + }, + defaultValue: newVarInputVal("str", getDesktopPath("output.mp4")), + }, + { + component: "OptionEditor", + width: 12, + options: { + width: { + label: "宽度", + component: "NumberInput", + width: 3, + min: -1, + placeholder: "保持比例填-1", + }, + height: { + label: "高度", + component: "NumberInput", + width: 3, + min: -1, + placeholder: "保持比例填-1", + }, + keepAspectRatio: { + label: "保持宽高比", + component: "CheckButton", + width: 3, + }, + overwrite: { + label: "覆盖已存在目标文件", + component: "CheckButton", + width: 3, + }, + }, + defaultValue: { + width: -1, + height: -1, + keepAspectRatio: true, + overwrite: true, + }, + }, + ], + }, + { + value: "quickcomposer.video.cropVideo", + label: "裁剪画面", + icon: "crop", + asyncMode: "await", + config: [ + { + label: "输入文件", + component: "VariableInput", + icon: "video_file", + width: 12, + options: { + dialog: { + type: "open", + options: { + title: "选择视频文件", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + }, + }, + }, + }, + { + label: "输出文件", + component: "VariableInput", + icon: "save", + width: 12, + options: { + dialog: { + type: "save", + options: { + title: "保存视频", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + defaultPath: "output.mp4", + }, + }, + }, + defaultValue: newVarInputVal("str", getDesktopPath("output.mp4")), + }, + { + component: "OptionEditor", + width: 12, + options: { + x: { + label: "X坐标", + component: "NumberInput", + width: 3, + min: 0, + placeholder: "起始X坐标", + }, + y: { + label: "Y坐标", + component: "NumberInput", + width: 3, + min: 0, + placeholder: "起始Y坐标", + }, + width: { + label: "宽度", + component: "NumberInput", + width: 3, + min: 1, + placeholder: "裁剪宽度", + }, + height: { + label: "高度", + component: "NumberInput", + width: 3, + min: 1, + placeholder: "裁剪高度", + }, + overwrite: { + label: "覆盖已存在目标文件", + component: "CheckButton", + width: 12, + }, + }, + defaultValue: { + x: 0, + y: 0, + width: 1920, + height: 1080, + overwrite: true, + }, + }, + ], + }, + { + value: "quickcomposer.video.extractFrames", + label: "导出帧序列", + icon: "burst_mode", + asyncMode: "await", + config: [ + { + label: "输入文件", + component: "VariableInput", + icon: "video_file", + width: 12, + options: { + dialog: { + type: "open", + options: { + title: "选择视频文件", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + }, + }, + }, + }, + { + label: "输出文件", + component: "VariableInput", + icon: "save", + width: 12, + description: "使用 %d 表示帧序号,例如: frame_%d.jpg", + options: { + dialog: { + type: "save", + options: { + title: "保存图片序列", + filters: [ + { + name: "JPG图片", + extensions: ["jpg"], + }, + { + name: "PNG图片", + extensions: ["png"], + }, + ], + defaultPath: "frame_%d.jpg", + }, + }, + }, + defaultValue: newVarInputVal("str", getDesktopPath("frame_%d.jpg")), + }, + { + component: "OptionEditor", + width: 12, + options: { + fps: { + label: "每秒帧数", + component: "NumberInput", + width: 3, + min: 0.1, + max: 60, + step: 0.1, + }, + format: { + label: "图片格式", + component: "QSelect", + width: 3, + options: IMAGE_FORMATS, + }, + quality: { + label: "图片质量", + component: "NumberInput", + width: 3, + min: 1, + max: 100, + placeholder: "1-100", + }, + overwrite: { + label: "覆盖已存在目标文件", + component: "CheckButton", + width: 3, + }, + }, + defaultValue: { + fps: 1, + format: "jpg", + quality: 90, + overwrite: true, + }, + }, + ], + }, + { + value: "quickcomposer.video.generateThumbnail", + label: "生成缩略图", + icon: "photo", + asyncMode: "await", + config: [ + { + label: "输入文件", + component: "VariableInput", + icon: "video_file", + width: 12, + options: { + dialog: { + type: "open", + options: { + title: "选择视频文件", + filters: [ + { + name: "视频文件", + extensions: ["mp4", "avi", "mkv", "mov", "wmv", "flv"], + }, + ], + }, + }, + }, + }, + { + label: "输出文件", + component: "VariableInput", + icon: "save", + width: 12, + options: { + dialog: { + type: "save", + options: { + title: "保存缩略图", + filters: [ + { + name: "JPG图片", + extensions: ["jpg"], + }, + { + name: "PNG图片", + extensions: ["png"], + }, + ], + defaultPath: "thumbnail.jpg", + }, + }, + }, + defaultValue: newVarInputVal("str", getDesktopPath("thumbnail.jpg")), + }, + { + component: "OptionEditor", + width: 12, + options: { + time: { + label: "时间点(秒)", + component: "NumberInput", + width: 3, + min: 0, + step: 0.1, + }, + width: { + label: "宽度", + component: "NumberInput", + width: 3, + min: 1, + }, + format: { + label: "图片格式", + component: "QSelect", + width: 3, + options: IMAGE_FORMATS, + }, + quality: { + label: "图片质量", + component: "NumberInput", + width: 3, + min: 1, + max: 100, + placeholder: "1-100", + }, + overwrite: { + label: "覆盖已存在目标文件", + component: "CheckButton", + width: 12, + }, + }, + defaultValue: { + time: 0, + width: 320, + format: "jpg", + quality: 90, + overwrite: true, + }, + }, + ], + }, + ], +}; diff --git a/src/js/composer/generateCode.js b/src/js/composer/generateCode.js index 0102ff9..e3f73e5 100644 --- a/src/js/composer/generateCode.js +++ b/src/js/composer/generateCode.js @@ -131,7 +131,5 @@ export function generateCode(flow) { const finalCode = code.join("\n"); - console.log(finalCode); - return finalCode; }