From 76ca080df7ff0e2b3f8d481908698f241f81a5a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E4=B8=AA=E6=88=90=E7=86=9F=E7=9A=84=E5=89=AA?= =?UTF-8?q?=E8=BE=91=E7=8C=BF?= <297668904+akahoz94@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:12:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=9F=B3=E9=A2=91?= =?UTF-8?q?=E6=8F=90=E5=8F=96=E6=8F=92=E4=BB=B6=20extract-audio=20v1.2.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- categories-mapping.json | 11 +- plugins/extract-audio/CHANGELOG.md | 20 ++ plugins/extract-audio/README.md | 47 +++ plugins/extract-audio/index.html | 343 ++++++++++++++++++++ plugins/extract-audio/logo.png | Bin 0 -> 781 bytes plugins/extract-audio/plugin.json | 38 +++ plugins/extract-audio/preload.js | 490 +++++++++++++++++++++++++++++ 7 files changed, 947 insertions(+), 2 deletions(-) create mode 100644 plugins/extract-audio/CHANGELOG.md create mode 100644 plugins/extract-audio/README.md create mode 100644 plugins/extract-audio/index.html create mode 100644 plugins/extract-audio/logo.png create mode 100644 plugins/extract-audio/plugin.json create mode 100644 plugins/extract-audio/preload.js diff --git a/categories-mapping.json b/categories-mapping.json index 6615434c2..a6f7ce863 100644 --- a/categories-mapping.json +++ b/categories-mapping.json @@ -104,6 +104,7 @@ "clipboard-image-paintbrush", "colors", "doutu", + "extract-audio", "ibox-wallpaper", "imgbatch", "long-screenshot", @@ -137,7 +138,13 @@ "title": "游戏娱乐", "description": "休闲娱乐小游戏", "icon": "https://raw.githubusercontent.com/ZToolsCenter/ZTools-plugins/refs/heads/main/icons/game.png", - "list": ["2048", "fidget", "flyfont", "rogue-shooter", "super-mario"] + "list": [ + "2048", + "fidget", + "flyfont", + "rogue-shooter", + "super-mario" + ] }, { "key": "network", @@ -177,4 +184,4 @@ "yanhua" ] } -] +] \ No newline at end of file diff --git a/plugins/extract-audio/CHANGELOG.md b/plugins/extract-audio/CHANGELOG.md new file mode 100644 index 000000000..66b2c9e72 --- /dev/null +++ b/plugins/extract-audio/CHANGELOG.md @@ -0,0 +1,20 @@ +# 更新日志 + +## 1.2.5 +- 完成提示卡片展示 5 秒 + +## 1.2.2 +- 新增 Windows 通知中心原生 toast(历史可查)+ 完成提示音 + +## 1.2.1 +- 修复大端 PCM(相机 twos)提取:按 WAV 规范重排字节序,位深/采样率不变 +- 归档防套娃:已在「已提取音频的视频」内的源视频不再重复移动 +- 修复提取失败时残留 20 字节空壳文件的问题 + +## 1.2.0 +- 双模式:中键/超级面板/粘贴 = 无界面静默(通知反馈,自动退出);搜索关键词 = 界面模式(进度/状态/定位) +- 提取完成可自动把源视频归入「已提取音频的视频」文件夹(只移动,不删除) +- 输出时长校验:结果异常短于源视频时判定失败并丢弃坏输出 + +## 1.0.0 +- 首发无界面版:选中视频一键无损提取音频(-c:a copy 原编码直出) diff --git a/plugins/extract-audio/README.md b/plugins/extract-audio/README.md new file mode 100644 index 000000000..073ee55ae --- /dev/null +++ b/plugins/extract-audio/README.md @@ -0,0 +1,47 @@ +# 音频提取(extract-audio) + +一键把视频里的音频**无损提取**出来。为「相机补录」场景设计: +出去拍视频需要补录音、只要声音不要画面时,用相机拍下的视频一键变成纯音频文件。 + +## 功能 + +- **带界面**:逐文件进度条、成功/失败状态、失败原因、一键定位音频文件。 +- **无损优先**:除大端 PCM 外全程 `-c:a copy` 零转码——位深/采样率/声道/每个采样值原样保留 + (16bit 永远是 16bit,32bit 永远是 32bit)。 +- **相机大端 PCM 自动出 .wav**:相机素材常见 twos/pcm_s16be 等大端音轨,而 WAV 规范只收小端, + 插件会按规范重排字节序后装进 .wav——采样值数学上完全等价、零损失,**不是重采样、不是转码**。 +- **自动选容器**:aac/alac→`m4a`、mp3→`mp3`、pcm→`wav`、opus→`opus`、vorbis→`ogg`、 + flac→`flac`、ac3→`ac3`、其他→`mka`(能装下任何编码的兜底容器)。 +- **提取完自动归档(可选,默认开)**:把已提取成功的源视频移入同目录的 + 「已提取音频的视频」文件夹——**只移动、绝不删除**,你核对完音频后一键删掉整个文件夹即可; + 提取失败/校验不过的视频原地不动。 +- **输出时长校验**:提取结果明显短于源视频会判定失败并丢弃坏文件(只删坏输出,源视频不动)。 +- **多文件批量**:一次选中多个视频逐个提取。 +- **输出位置**:与源视频同目录、同名只换后缀;重名自动追加 ` (1)`、` (2)`;源目录不可写时回退桌面。 +- **无声视频**:探测不到音轨会明确报错,不会生成坏文件。 + +## 用法(双模式) + +1. **中键/超级面板/粘贴文件 → 无界面静默模式**:不弹任何窗口,后台提取完发系统通知 + 「✅ 音频已提取(N/N)」,源视频自动归入「已提取音频的视频」,插件自动退出。要的就是快。 + > 首次使用需固定一次:搜索「提取音频」→ 右键搜索结果 → **固定到超级面板**。 +2. **Alt+Space 搜索「提取音频」→ 界面模式**:选视频文件、看逐文件进度、成功/失败状态、 + 定位音频文件;界面里的「提取完归档」勾选与无界面模式共用同一份偏好。 +3. 两种模式共用归档开关:**只移动、绝不删除**,失败/校验不过的视频原地不动。 + +## 依赖 + +- **优先使用系统已安装的 ffmpeg**(自动探测 PATH、`FFMPEG_PATH` 环境变量及常见安装路径)。 +- 系统没有 ffmpeg 时才回退 ZTools 内置 `runFFmpeg`(会弹集成下载窗,需联网)。 + +## 排查 + +每次提取都写日志到 `%TEMP%\audio-extract-debug.log`,失败时可直接查原因。 + +## 开发 + +- 自检:`node test.js`(合成 AAC/PCM/大端PCM/无声 等测试视频,跑通 探测→提取→校验→归档 全链路, + 含"位深必须原样保留"回归断言)。 +- 重新生成 logo:`python logo_gen.py`(纯 Python,无第三方依赖)。 +- 打包:仓库根目录 `powershell -File _pack_root.ps1 extract-audio 音频提取-ztools.zip`。 +- 本地安装:ZTools → 设置 → 开发者 → 新建项目指向本目录的 `plugin.json`。 diff --git a/plugins/extract-audio/index.html b/plugins/extract-audio/index.html new file mode 100644 index 000000000..bf359eab0 --- /dev/null +++ b/plugins/extract-audio/index.html @@ -0,0 +1,343 @@ + + + + +音频提取 + + + +
+ + + + diff --git a/plugins/extract-audio/logo.png b/plugins/extract-audio/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..dbbd51612fa6d2fcdd3cc70cdedfb51abfe4f25b GIT binary patch literal 781 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV7lVz;uunK>+M~~yxRr>tO55{ zxSuX8nlf+I?Y!WWizXg#th!b_=ECNO9igRinwkrzw$ue9P{^6<<5JPeI7_vh9BtIJ;P{$HJisf$}4C}Hq-J->Xzv&A6xj;!}}tuN&p z7POl&d^p0#F!!+i=Lm5IhA+$)w#NN;-NLfqU#)uvtHa%W!5NGWrfdt|wafP|;Axn< zfBqJx3AP`27^c19dbNpv-KG6&${2R(vMs$OzGIj2?zL=bFDC~ugu4G1<+x-klk{bM z?R0qtf7|%yTn>Q@>17;@8zdW|fB);#d$EOe!|P+`->-SW7@%;W`r)~VbF2#je!O>{ zXZVZJW0PNj${+XKV`~){8X3~s>|bA94|FvUOkpzk{PsSO;jnc#0|VE3<}+OTz5X*8 z2s$49$C&V_OMyZ9pM;309fK5DIToVfFT?NooGhp^39<*QP(@IAK1>3Q3=J`VjqRqz zGgtqW;$UES&A4a(iE|8LG3HREE{Z4Wx}Iq)Vz;j1qNzOlYm#2{e8FVFN)-##Sj11E!v;tMvV zP4>roHYG1llQ#BlH{%0JIxEx&u<$p2a1^lNXli$SQNR&-b-n%JqlflhSDa%9a)zg? KpUXO@geCw&TOp_b literal 0 HcmV?d00001 diff --git a/plugins/extract-audio/plugin.json b/plugins/extract-audio/plugin.json new file mode 100644 index 000000000..672b7ac13 --- /dev/null +++ b/plugins/extract-audio/plugin.json @@ -0,0 +1,38 @@ +{ + "name": "extract-audio", + "title": "音频提取", + "description": "一键把视频里的音频无损提取出来(不重编码,位深/采样率原样;相机大端 PCM 按 WAV 规范重排字节后出 .wav)。中键/超级面板/粘贴触发=无界面静默模式:不弹窗,提取完系统通知「音频已提取」,视频自动归入待删文件夹;搜索关键词进入=界面模式:选文件、看进度。优先用系统 ffmpeg,无则回退 ZTools 内置。", + "author": "一个成熟的剪辑猿", + "version": "1.2.5", + "logo": "logo.png", + "main": "index.html", + "preload": "preload.js", + "features": [ + { + "code": "extract", + "explain": "从视频无损提取音频(无界面,提取完通知)", + "mainHide": true, + "cmds": [ + { + "type": "files", + "label": "提取音频", + "fileType": "file", + "extensions": [ + "mp4", "mov", "m4v", "mkv", "avi", "flv", "wmv", "webm", + "ts", "mts", "m2ts", "m2t", "mpg", "mpeg", "mxf", "f4v", + "asf", "3gp", "3g2", "rmvb", "rm", "vob", "ogv", "dv", "mod" + ] + } + ] + }, + { + "code": "extract-ui", + "explain": "音频提取(界面模式:选择视频文件,查看进度)", + "cmds": [ + "提取音频", + "音频提取", + "extractaudio" + ] + } + ] +} diff --git a/plugins/extract-audio/preload.js b/plugins/extract-audio/preload.js new file mode 100644 index 000000000..5d9458cec --- /dev/null +++ b/plugins/extract-audio/preload.js @@ -0,0 +1,490 @@ +// ZTools 插件 preload:视频 → 无损提取音频(原编码直出,不重转码) +// 运行在 Node 上下文,可直接使用 require / fs / path / child_process。 +// 架构沿用 zt-ncm-converter 的生产经验: +// 1. 优先调用系统已安装的 ffmpeg(探测 PATH 与常见安装路径), +// 避免触发 ZTools 内置 runFFmpeg 的 FFmpeg 下载弹窗(下载节点实测不可达); +// 2. 仅当系统完全没有 ffmpeg 时,才回退 ZTools 内置 runFFmpeg。 +// 职责边界:preload 只干 node/ffmpeg 的活(探测/提取/校验/移动), +// UI 在 index.html,两者通过 window.audioBridge 与 CustomEvent 通讯。 +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { spawn, execFileSync } = require('child_process'); + +// 诊断日志:每次提取都写 %TEMP%\audio-extract-debug.log,失败时可直接查原因 +const LOG_FILE = path.join(os.tmpdir(), 'audio-extract-debug.log'); +function log(msg) { + try { + fs.appendFileSync(LOG_FILE, `[${new Date().toLocaleString('zh-CN')}] ${msg}\n`); + } catch (e) {} +} + +// 调试用:把结构压缩成可读摘要 +function summarize(v) { + try { + if (typeof v === 'object') return JSON.stringify(v).slice(0, 400); + return String(v); + } catch (e) { + return '[unstringifiable ' + (v && v.constructor && v.constructor.name) + ']'; + } +} + +// 本地测试 / 非 ZTools 环境 fallback:探测系统 ffmpeg +let _ffmpeg; +function findFfmpeg() { + if (_ffmpeg !== undefined) return _ffmpeg; + const cands = [ + process.env.FFMPEG_PATH, + 'ffmpeg', + 'C:/Program Files/ffmpeg-2022-11-03/bin/ffmpeg.exe', + 'C:/Program Files/ffmpeg/bin/ffmpeg.exe', + 'C:/Program Files (x86)/ffmpeg/bin/ffmpeg.exe', + 'C:/ffmpeg/bin/ffmpeg.exe' + ].filter(Boolean); + for (const c of cands) { + try { + execFileSync(c, ['-version'], { stdio: 'ignore', windowsHide: true }); + _ffmpeg = c; + return c; + } catch (e) {} + } + _ffmpeg = null; + return null; +} + +// 运行 ffmpeg:优先系统已安装的 ffmpeg,仅当系统无 ffmpeg 时退回 ZTools 内置。 +// opts.onProgress(percent) 仅系统 ffmpeg 路径用:解析 -progress pipe:1 的 out_time_ms +function runFfmpeg(args, opts) { + const onProgress = opts && typeof opts.onProgress === 'function' ? opts.onProgress : null; + const ff = findFfmpeg(); + if (ff) { + return new Promise((resolve, reject) => { + log('ffmpeg 命令: ' + ff + ' ' + args.map(a => (/[\s"']/.test(String(a)) ? `"${a}"` : a)).join(' ')); + const cp = spawn(ff, args, { windowsHide: true }); + let err = ''; + if (cp.stderr) cp.stderr.on('data', d => { err += d.toString(); }); + if (cp.stdout && onProgress) { + let buf = ''; + cp.stdout.on('data', d => { + buf += d.toString(); + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i); + buf = buf.slice(i + 1); + const m = /out_time_ms=(\d+)/.exec(line) || /out_time_us=(\d+)/.exec(line); + // ffmpeg 的 out_time_ms 名不副实,单位是微秒 + if (m) onProgress(parseInt(m[1], 10) / 1e6); + } + }); + } + cp.on('error', e => { log('spawn 错误: ' + e.message); reject(e); }); + cp.on('close', code => { + if (code === 0) { log('ffmpeg 成功'); return resolve(); } + const tail = err.trim() ? err.trim().split('\n').slice(-8).join('\n') : '(ffmpeg 无 stderr 输出)'; + log('ffmpeg 失败 code=' + code + '\n' + tail); + reject(new Error(`ffmpeg 退出码 ${code}\n${tail}`)); + }); + }); + } + if (typeof window !== 'undefined' && window.ztools && typeof window.ztools.runFFmpeg === 'function') { + return window.ztools.runFFmpeg(args, { + onLog: () => {}, + onProgress: p => { if (onProgress && p && typeof p.percent === 'number') onProgress(p.percent / 100); } + }); + } + return Promise.reject(new Error('未检测到 ffmpeg。请安装 ffmpeg 并加入 PATH,或设置环境变量 FFMPEG_PATH。')); +} + +// 从 ffmpeg 的 stderr 里解析第一条音轨的编码名(aac/mp3/opus/...) +function parseCodec(stderrText) { + const m = /Audio:\s*([A-Za-z0-9_]+)/.exec(stderrText || ''); + return m ? m[1].toLowerCase() : null; +} + +// 从 ffmpeg 的 stderr 里解析容器总时长(秒) +function parseDuration(stderrText) { + const m = /Duration:\s*(\d+):(\d{2}):(\d{2})\.(\d+)/.exec(stderrText || ''); + if (!m) return 0; + return parseInt(m[1], 10) * 3600 + parseInt(m[2], 10) * 60 + parseInt(m[3], 10) + parseInt(m[4], 10) / 100; +} + +// 探测文件:第一条音轨的编码 + 容器时长。探测不到音轨时 codec 为 null +function probeAudio(file) { + const args = ['-hide_banner', '-i', file]; + const ff = findFfmpeg(); + if (ff) { + return new Promise((resolve) => { + const cp = spawn(ff, args, { windowsHide: true }); + let err = ''; + if (cp.stderr) cp.stderr.on('data', d => { err += d.toString(); }); + cp.on('error', () => resolve({ codec: null, duration: 0 })); + cp.on('close', () => resolve({ codec: parseCodec(err), duration: parseDuration(err) })); + }); + } + // 回退 ZTools 内置 runFFmpeg:探测命令没有输出文件,ffmpeg 必定报错退出, + // 但流信息已经在 stderr(onLog)里了,照常解析即可 + if (typeof window !== 'undefined' && window.ztools && typeof window.ztools.runFFmpeg === 'function') { + let text = ''; + return window.ztools.runFFmpeg(args, { onLog: line => { text += line + '\n'; } }) + .then(() => ({ codec: parseCodec(text), duration: parseDuration(text) })) + .catch(() => ({ codec: parseCodec(text), duration: parseDuration(text) })); + } + return Promise.resolve({ codec: null, duration: 0 }); +} + +// 编码 → 输出容器后缀。原则:除大端 PCM 的字节序重排外,全程 -c:a copy 零转码, +// 位深/采样率/声道/采样值一律原样(16bit 永远是 16bit,32bit 永远是 32bit)。 +// 大端 PCM(pcm_*be,相机 twos 等):WAV 规范只收小端,按规范重排字节后进 .wav, +// 采样值数学上完全等价、零损失;其余 pcm(小端)直接进 wav; +// aac/alac 进 m4a;mka(Matroska 纯音频)几乎能装下任何编码,做默认兜底。 +const CODEC_EXT = { + aac: 'm4a', alac: 'm4a', mp3: 'mp3', opus: 'opus', vorbis: 'ogg', + flac: 'flac', ac3: 'ac3', eac3: 'eac3', amr_nb: 'amr', amr_wb: 'amr' +}; +function codecToExt(codec) { + if (!codec) return 'mka'; + if (codec.indexOf('pcm') === 0) return 'wav'; + return CODEC_EXT[codec] || 'mka'; +} + +// 目录是否可写(用于输出目录回退) +function isWritable(dir) { + try { + fs.accessSync(dir, fs.constants.W_OK); + return true; + } catch (e) { + return false; + } +} + +// 生成不冲突的输出路径(同名文件已存在时追加 " (1)"、" (2)"…) +function uniquePath(dir, name) { + let p = path.join(dir, name); + let i = 1; + while (fs.existsSync(p)) { + const ext = path.extname(name); + const base = name.slice(0, -ext.length); + p = path.join(dir, `${base} (${i})${ext}`); + i++; + } + return p; +} + +// 提取单个视频的音频:探测编码 → 选容器 → 原样抽出 → 时长校验。 +// opts.onProgress(sec) 已处理的音频秒数 +// opts.validate 是否做输出时长校验(默认开) +// 只提取、只生成文件,**绝不删除/移动源视频**——归档由 UI 层显式调用 moveExtractedVideo。 +async function extractFile(filePath, opts) { + const onProgress = opts && typeof opts.onProgress === 'function' ? opts.onProgress : null; + const validate = !opts || opts.validate !== false; + + const { codec, duration } = await probeAudio(filePath); + log(`探测 ${filePath} 音轨编码: ${codec || '(无音轨)'} 时长: ${duration}s`); + if (!codec) throw new Error('未探测到音频轨(视频可能没有声音或文件损坏)'); + + const base = path.basename(filePath, path.extname(filePath)); + let outDir = path.dirname(filePath); + // 源目录不可写(网盘/只读/受保护目录)时回退到桌面,避免整单失败 + if (!isWritable(outDir)) { + const desk = path.join(os.homedir(), 'Desktop'); + log(`源目录不可写,回退桌面: ${outDir} -> ${desk}`); + outDir = isWritable(desk) ? desk : os.tmpdir(); + } + const outPath = uniquePath(outDir, `${base}.${codecToExt(codec)}`); + log(`提取: ${filePath} -> ${outPath}`); + + // 除大端 PCM 外全程 -c:a copy 零转码。-map 0:a:0 只取第一条音轨(避开封面流/多轨干扰) + const args = ['-y', '-i', filePath, '-map', '0:a:0', '-vn']; + if (codec.indexOf('pcm_') === 0 && /be$/.test(codec)) { + // 相机素材的大端 PCM(twos/pcm_s16be 等)装不进 WAV——WAV 规范只收小端 + // (ffmpeg 报 "Codec pcm_s16be not supported in WAVE format")。按 WAV 规范 + // 重排字节序后进 .wav:PCM→PCM 仅字节表示不同,位深/采样率/声道/采样值全部 + // 原样(16bit 仍是 16bit,32bit 仍是 32bit),数学意义上零损失,不是重采样/转码。 + // 注意编码名结尾是数字+be(pcm_s16be),不是 _be。 + const le = codec.replace(/be$/, 'le'); + log(`大端 PCM 按 WAV 规范重排字节序: ${codec} -> ${le}(位深等规格不变)`); + args.push('-c:a', le); + } else { + args.push('-c:a', 'copy'); + } + if (onProgress) args.push('-progress', 'pipe:1', '-nostats'); + args.push(outPath); + + try { + await runFfmpeg(args, onProgress ? { onProgress: sec => onProgress(Math.min(100, Math.round(sec / (duration || 1) * 100))) } : null); + } catch (e) { + // ffmpeg 失败时输出文件可能已建出(20 字节空壳),删掉避免误导 + try { fs.unlinkSync(outPath); } catch (_) {} + throw e; + } + + if (validate) { + const ok = await checkOutputDuration(outPath, duration); + if (!ok) { + try { fs.unlinkSync(outPath); } catch (_) {} + const e = new Error('输出时长异常(可能截断),已丢弃结果,源视频未受影响'); + log(`校验失败 ${outPath}: 期望≈${duration}s`); + throw e; + } + } + return outPath; +} + +// 输出时长校验:明显短于源(<90%)视为提取失败。 +// ponytail: 粗粒度启发式,只防"截断/坏文件"这一类事故;流式源无精确音轨时长,用容器时长兜底。 +async function checkOutputDuration(outPath, sourceDuration) { + if (!sourceDuration) return true; // 探测不到源时长时只能放行 + const { duration: outDur } = await probeAudio(outPath); + if (!outDur) return false; + return outDur >= sourceDuration * 0.9; +} + +// 把提取成功的源视频移动到源目录下的归档子文件夹(只移动,不删除,随时可拖回)。 +// 源已经在归档文件夹里时不重复移动(防「已提取音频的视频」层层套娃),返回 null。 +function moveExtractedVideo(videoPath, folderName) { + const name = folderName || '已提取音频的视频'; + if (path.basename(path.dirname(videoPath)) === name) { + log('已在归档文件夹内,跳过移动: ' + videoPath); + return null; + } + const targetDir = path.join(path.dirname(videoPath), name); + if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true }); + const dest = uniquePath(targetDir, path.basename(videoPath)); + fs.renameSync(videoPath, dest); + log(`归档: ${videoPath} -> ${dest}`); + return dest; +} + +// 保证可见的完成提示:系统通知(Electron Notification)在缺 AUMID 注册的 Windows 构建 +// 上会静默失败,这里兜底弹一个右下角自毁小窗(真实窗口,不依赖系统通知权限)。 +// 窗口属于插件进程,宿主在 enter 的 Promise resolve 后才退出插件,所以调用方需 dwell 几秒。 +async function showToastWindow(title, lines, closeAfterMs) { + try { + if (typeof window === 'undefined' || !window.ztools || typeof window.ztools.createBrowserWindow !== 'function') return; + const disp = typeof window.ztools.getPrimaryDisplay === 'function' ? window.ztools.getPrimaryDisplay() : null; + const wa = (disp && disp.workArea) || { x: 0, y: 0, width: 1920, height: 1080 }; + const W = 380; + const H = 74 + lines.length * 22; + const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const items = lines.map(l => { + const bad = l.indexOf('✗') === 0; + return `
${esc(l)}
`; + }).join(''); + const html = ` +
${esc(title)}
${items} + + `; + const file = path.join(os.tmpdir(), 'ztools-toast-' + Date.now() + '.html'); + fs.writeFileSync(file, html); + const url = 'file:///' + file.replace(/\\/g, '/'); + window.ztools.createBrowserWindow(url, { + width: W, + height: H, + frame: false, + alwaysOnTop: true, + resizable: false, + skipTaskbar: true, + focusable: false, + show: true, + x: wa.x + wa.width - W - 16, + y: wa.y + wa.height - H - 16 + }); + setTimeout(() => { try { fs.unlinkSync(file); } catch (e) {} }, (closeAfterMs || 2800) + 8000); + } catch (e) { + log('toast 窗口失败: ' + (e && e.message ? e.message : e)); + } +} + +// ── 无界面模式(中键/超级面板/粘贴 files 指令触发)──────────────────────── +// 不弹窗口、干完活发系统通知,enter 的 Promise resolve 后宿主自动退出插件。 + +// 统一通知(ZTools 通知;测试环境降级为 console) +function notify(text) { + try { + if (typeof window !== 'undefined' && window.ztools && typeof window.ztools.showNotification === 'function') { + window.ztools.showNotification(text); + return; + } + } catch (e) {} + try { console.log('[notify] ' + text) } catch (e) {} +} + +// 把 action 里各种可能形态的文件描述提取为真实存在的文件路径 +function collectPaths(action) { + log('收到 action: ' + summarize(action)); + let payload = action; + if (action && typeof action === 'object') { + if ('payload' in action) payload = action.payload; + else if ('files' in action) payload = action.files; + } + const list = Array.isArray(payload) ? payload : [payload]; + const out = []; + for (const item of list) { + if (item == null) continue; + if (typeof item === 'string') { out.push(item); continue; } + if (typeof item === 'object') { + const p = + item.path || item.filePath || item.realPath || + (item.data && (item.data.path || item.data.filePath)) || + item.name; + if (typeof p === 'string') out.push(p); + } + } + return out.filter(p => { + try { return fs.statSync(p).isFile(); } catch (e) { return false; } + }); +} + +// 调试用:把结构压缩成可读摘要 +function summarize(v) { + try { + if (typeof v === 'object') return JSON.stringify(v).slice(0, 400); + return String(v); + } catch (e) { + return '[unstringifiable ' + (v && v.constructor && v.constructor.name) + ']'; + } +} + +// 没拿到文件时弹系统选择框(showOpenDialog 可能同步返回也可能返回 Promise,两种都处理) +async function pickDialog() { + if (typeof window === 'undefined' || !window.ztools || typeof window.ztools.showOpenDialog !== 'function') return null; + let picked = window.ztools.showOpenDialog({ + title: '选择要提取音频的视频文件', + properties: ['openFile', 'multiSelections'], + filters: [{ + name: 'Video', + extensions: ['mp4', 'mov', 'm4v', 'mkv', 'avi', 'flv', 'wmv', 'webm', 'ts', 'mts', 'm2ts', 'mpg', 'mpeg', 'mxf', '3gp', 'rmvb'] + }] + }); + if (picked && typeof picked.then === 'function') { + try { picked = await picked } catch (e) { picked = null } + } + if (picked && !Array.isArray(picked) && Array.isArray(picked.filePaths)) picked = picked.filePaths; + return Array.isArray(picked) ? picked : null; +} + +// 归档开关:UI 里的勾选存在 dbStorage,无界面模式读同一份(默认开) +function archiveEnabled() { + try { + return window.ztools.dbStorage.getItem('archive_default') !== false; + } catch (e) { + return true; + } +} + +// 进 Windows 通知中心(Action Center)的原生 toast:横幅显示后留在通知中心可查历史。 +// 用 PowerShell WinRT 发送,挂在 PowerShell 自己的 AUMID 下(永远已注册,不受宿主 +// AUMID 缺失影响);走 -EncodedCommand 避免引号/中文转义问题;子进程独立于插件进程存活。 +function sendActionCenterToast(title, lines) { + try { + const esc = s => String(s).replace(/&/g, '&').replace(//g, '>').replace(/'/g, ''').replace(/"/g, '"'); + const texts = [esc(title)].concat(lines.slice(0, 8).map(esc)) + .map(t => '' + t + '').join(''); + const xml = + '' + texts + '' + + ''; // 声音由 shellBeep 负责,toast 静音避免双重响 + const ps = ` +[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null +[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null +$x = New-Object Windows.Data.Xml.Dom.XmlDocument +$x.LoadXml('${xml.replace(/'/g, "''")}') +$t = [Windows.UI.Notifications.ToastNotification]::new($x) +[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($t) +`; + const b64 = Buffer.from(ps, 'utf16le').toString('base64'); + spawn('powershell', ['-NoProfile', '-NonInteractive', '-EncodedCommand', b64], { windowsHide: true, stdio: 'ignore' }); + log('通知中心 toast 已发送'); + } catch (e) { + log('通知中心 toast 失败: ' + (e && e.message ? e.message : e)); + } +} + +// 无界面入口:提取全部文件 → 通知结果 → resolve 后宿主自动退出 +async function handleHeadless(action) { + try { + let files = collectPaths(action); + if (!files.length) files = (await pickDialog()) || []; + if (!files.length) { log('未获取到视频文件,放弃'); return; } + + notify(`开始提取 ${files.length} 个文件的音频…`); + + const lines = []; + const done = []; + let okCount = 0; + for (const f of files) { + try { + const out = await extractFile(f, { + onProgress: () => {} + }); + okCount++; + done.push({ src: f, out }); + lines.push('✓ ' + path.basename(out)); + } catch (e) { + const all = (e && e.message ? e.message : String(e)).split('\n'); + const msg = all.find(l => l.trim() && l.indexOf('ffmpeg 退出码') !== 0) || all[0]; + log('提取失败 ' + f + ' : ' + msg); + lines.push('✗ ' + path.basename(f) + ': ' + msg); + } + } + + // 归档:只移动提取成功的源视频,失败的不动;已在归档文件夹里的不重复移动 + let archived = 0; + let alreadyArchived = 0; + if (archiveEnabled()) { + for (const d of done) { + try { + const dest = moveExtractedVideo(d.src); + if (dest === null) alreadyArchived++; + else archived++; + } catch (e) { log('归档失败 ' + d.src + ' : ' + (e && e.message)); } + } + } + + const head = okCount === files.length + ? `✅ 音频已提取(${okCount}/${files.length})` + : `音频提取完成(${okCount}/${files.length})`; + let body = lines.slice(0, 10).join('\n'); + if (lines.length > 10) body += `\n…等 ${lines.length} 个文件`; + if (archived) body += `\n已提取的视频(${archived})移入「已提取音频的视频」`; + else if (alreadyArchived) body += `\n源视频已在归档文件夹,未重复移动`; + + // 四通道反馈:提示音(听得到)+ 右下角小窗(5 秒,历史在通知中心) + // + 通知中心原生 toast(可查历史)+ 宿主系统通知(锦上添花)。 + // 小窗属于插件进程,宿主要等 enter resolve 才退插件,dwell 略长于小窗时长。 + try { if (window.ztools && typeof window.ztools.shellBeep === 'function') window.ztools.shellBeep(); } catch (e) {} + await showToastWindow(head, body.split('\n'), 5000); + sendActionCenterToast(head, body.split('\n')); + notify(head + '\n' + body); + await new Promise(r => setTimeout(r, 5500)); + } catch (e) { + log('整体失败: ' + (e && e.message ? e.message : e)); + notify('音频提取失败: ' + (e && e.message ? e.message : e)); + } +} + +// ── 双模式装配 ────────────────────────────────────────────── +// extract files 指令(中键/超级面板/粘贴)→ mode:'none' 无界面,配 mainHide:true 不弹窗 +// extract-ui 关键词(Alt+Space 搜索)→ 无 mode 带界面,入口在 index.html 的 onPluginEnter +window.exports = { + extract: { mode: 'none', args: { enter: handleHeadless } } +}; + +// 渲染层桥:node/ffmpeg 能力全在这里,UI 只管展示。 +// 注意:带界面的插件,进入参数由宿主派发给渲染层的 ztools.onPluginEnter, +// window.exports.args.enter 只对 mode:'none' 无界面插件生效(ZTools 宿主 shim 实证)。 +window.audioBridge = { + extractFile, + moveExtractedVideo, + LOG_FILE +}; + +// 本地测试导出 +if (typeof module !== 'undefined' && module.exports) { + module.exports = { collectPaths, probeAudio, parseCodec, parseDuration, codecToExt, extractFile, moveExtractedVideo, checkOutputDuration, findFfmpeg, runFfmpeg, handleHeadless }; +}