dsh-ffmpeg 0.4.4 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # 更新记录
2
+
3
+ [返回简介](README.md) · [使用说明](docs/USAGE.md) · [验证记录](docs/VALIDATION.md)
4
+
5
+ [历史英文记录](docs/CHANGELOG.en.md)
6
+
7
+ ## 0.4.5 (2026-10-05)
8
+
9
+ - 输入、输出、字幕与抽帧路径按当前 DSH 会话工作区解析,避免宿主启动目录造成找错文件;非法时间参数立即报错。
10
+
11
+ ## 0.4.4 (2026-09-28)
12
+
13
+ - 更新官方 Harness 0.2.0-rc.1 的兼容声明和共同加载验证;运行时代码未变。验证范围见[验证记录](docs/VALIDATION.md)。
14
+
15
+ ## 0.4.3 (2026-09-19)
16
+
17
+ - 修复抽帧数到旧帧/清单截断、取消被当成功、取消文案丢原因、`+2dB` 被拒;音轨提取非 AAC 自动转 AAC、concat 混合音轨可用、ffprobe 截断明确报错。测试 102 项。
18
+
19
+ ## 更早的改动
20
+
21
+ 完整历史可查阅 [GitHub 提交记录](https://github.com/STARDUSTLC666/dsh-ffmpeg/commits/master)。
package/README.en.md CHANGED
@@ -1,92 +1,46 @@
1
- [中文](README.md)
2
-
3
- ![npm](https://img.shields.io/npm/v/dsh-ffmpeg) ![downloads](https://img.shields.io/npm/dm/dsh-ffmpeg) ![license](https://img.shields.io/github/license/STARDUSTLC666/dsh-ffmpeg) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-ffmpeg?style=social)
4
-
5
1
  # dsh-ffmpeg
6
2
 
7
- [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
8
-
9
- DSH (DeepSeek Harness) video-processing plugin: seven tools covering probing, cutting, concatenation, transcoding, subtitles, extraction and GIF creation — all powered by ffmpeg/ffprobe.
3
+ [中文](README.md)
10
4
 
11
- ## Compatibility
5
+ ![dsh-ffmpeg whale girl plugin cover](https://raw.githubusercontent.com/STARDUSTLC666/dsh-ffmpeg/master/assets/cover-whale-girl.png)
12
6
 
13
- Validation host: Harness `0.2.0-rc.1` built from official sources (commit `407e65c8`) with Node `24.16.0` on 2026-09-28. All 96 plugin tests pass in an isolated environment; all 18 plugins mount together in one host registering 10 tools, with tool schemas and health-check contracts passing. No live ports or external services were exercised in this round.
7
+ Use FFmpeg to process existing audio and video files through natural-language requests.
14
8
 
15
- 2026-09-13 fix: retain the service receiver when calling `subprocess.spawn`, preventing failures caused by passing the method as an unbound callback. Verified with official `0.1.5-rc.1` and `0.1.5-rc.2` on Node `24.16.0`. FFmpeg/ffprobe version checks and probing a real MP4 pass. This fixes the tool invocation failure reported in [#3](https://github.com/STARDUSTLC666/dsh-ffmpeg/issues/3).
9
+ [![npm](https://img.shields.io/npm/v/dsh-ffmpeg)](https://www.npmjs.com/package/dsh-ffmpeg) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-ffmpeg-downloads.svg)](https://www.npmjs.com/package/dsh-ffmpeg)
16
10
 
17
- ## Installation
11
+ ## What it does
18
12
 
19
- ```bash
20
- dsh plugin --profile web add dsh-ffmpeg
21
- ```
13
+ - Inspect media and trim, join or transcode video.
14
+ - Add subtitles, create GIFs and extract frames.
15
+ - Execute commands through the host subprocess service.
22
16
 
23
- ffmpeg must be installed locally (`ffmpeg -version` should work); use `ffmpegPath` / `ffprobePath`, or the `DSH_FFMPEG_PATH` / `DSH_FFPROBE_PATH` environment variables, when it is not on PATH.
17
+ ## Install
24
18
 
25
- ## Uninstall
19
+ In DSH Desktop, install `dsh-ffmpeg` from the Plugins panel. If the bundled dsh command is available:
26
20
 
27
21
  ```bash
28
- dsh plugin --profile web remove dsh-ffmpeg
22
+ dsh plugin --profile desktop add dsh-ffmpeg
29
23
  ```
30
24
 
31
- Then restart the web service. To clean up fully, also remove the plugin entry from your profile `cordis.patch.yml` if you overrode it.
25
+ For the web version, replace `desktop` with `web`. Restart DSH after installation.
32
26
 
27
+ ## Start using it
33
28
 
34
- ## Configuration
29
+ Provide an input file and ask: “Trim this video to 30 seconds and export a shareable MP4.”
35
30
 
36
- Override the plugin row in your profile's `cordis.patch.yml` (defaults apply when absent):
31
+ ## Requirements and configuration
37
32
 
38
- ```yaml
39
- - id: ffmpeg
40
- name: 'dsh-ffmpeg'
41
- config:
42
- # ffmpegPath: C:\tools\ffmpeg\bin\ffmpeg.exe # explicit path (or DSH_FFMPEG_PATH)
43
- # ffprobePath: C:\tools\ffmpeg\bin\ffprobe.exe # or DSH_FFPROBE_PATH
44
- timeoutMs: 300000 # per-operation timeout (default 5 min, 10s - 2h)
45
- # overwrite: true # allow overwriting outputs (default auto-suffix _1/_2)
46
- ```
33
+ Requires FFmpeg and ffprobe. Executable paths can be configured.
47
34
 
48
- ## Tools
49
-
50
- | Tool | Purpose | Key parameters |
51
- | :-- | :-- | :-- |
52
- | `ffmpeg_probe` | Probe media info (format/duration/resolution/fps/bitrate/audio/subtitle streams; multi-video files return a full videos list) | `input` required |
53
- | `ffmpeg_cut` | Cut a clip (stream copy by default, accurate re-encode optional) | `input` required; `start`/`end`/`duration` |
54
- | `ffmpeg_concat` | Concatenate 2-20 clips (stream copy for identical codecs / re-encode for mixed; video-only when any input has no audio) | `inputs` array required |
55
- | `ffmpeg_encode` | Transcode with presets (bilibili 1080p/4K, vertical 1080p, web-720p) plus crf/fps/scale overrides | `input` required; `preset` optional |
56
- | `ffmpeg_subtitle` | Burn subtitles (SRT/ASS hard subs) | `input` + `subtitle` required |
57
- | `ffmpeg_extract` | Extract audio (m4a; non-AAC is auto-transcoded to AAC) / frame sequences / single frame / subtitle stream | `input` + `what` required |
58
- | `ffmpeg_gif` | Video to high-quality GIF (two-pass palette) | `input` required; `fps`/`width`/`duration` optional |
59
-
60
- ### Examples
61
-
62
- ```text
63
- ffmpeg_probe { input: E:\videos\raw.mp4 }
64
- ffmpeg_cut { input: E:\videos\raw.mp4, start: 10, end: 30 }
65
- ffmpeg_encode { input: E:\videos\raw.mp4, preset: bilibili-1080p }
66
- ffmpeg_subtitle { input: E:\videos\raw.mp4, subtitle: E:\videos\subs.srt }
67
- ffmpeg_extract { input: E:\videos\raw.webm, what: audio }
68
- ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
69
- ```
70
-
71
- ## Safety
35
+ Detailed configuration, tool arguments and troubleshooting are in the [usage guide](docs/USAGE.en.md). For standalone development, follow the Node requirement in [package.json](package.json).
72
36
 
73
- - **No shell**: every argument is passed as its own argv element — user input cannot inject commands
74
- - **Runs on the official DSH subprocess service**: the timeout AbortSignal now really drives tree-scoped termination (SIGTERM → kill; taskkill /T on Windows), zero runtime dependencies
75
- - **No accidental overwrites**: existing outputs get auto-suffixed; output == input is rejected
76
- - **Timeout clamps**: per-operation 10s - 2h; probes additionally capped at 60s
77
- - **Input validation**: time formats, preset enums, crf/fps/scale ranges are validated up front; directory inputs are rejected and extension-less frame-sequence outputs are auto-fixed
37
+ ## Documentation
78
38
 
79
- ## Development
80
-
81
- ```bash
82
- pnpm install
83
- pnpm test # build + 102 tests, including a real-ffmpeg end-to-end suite (auto-skipped without ffmpeg)
84
- ```
39
+ - [Usage and troubleshooting](docs/USAGE.en.md)
40
+ - [Changelog](CHANGELOG.md)
41
+ - [Validation scope and history](docs/VALIDATION.md)
42
+ - [Report a problem or suggest a feature](https://github.com/STARDUSTLC666/dsh-ffmpeg/issues)
85
43
 
86
44
  ## License
87
45
 
88
- MIT
89
-
90
- ## Changelog
91
-
92
- - **0.4.3 (2026-09-18)**: 修复抽帧数到旧帧/清单截断、取消被当成功、取消文案丢原因、`+2dB` 被拒; 音轨提取非 AAC 自动转 AAC、concat 混合音轨可用、ffprobe 截断明确报错. 测试 102 项.
46
+ [MIT](LICENSE)
package/README.md CHANGED
@@ -1,97 +1,46 @@
1
- [English](README.en.md)
2
-
3
1
  # dsh-ffmpeg
4
2
 
5
- > **你的 agent 会剪视频了**:探测/剪辑/拼接/转码/字幕/抽帧/GIF/变速旋转调音,十个工具一条命令。
6
-
7
- ![npm version](https://img.shields.io/npm/v/dsh-ffmpeg?label=npm&color=blue) ![npm downloads](https://img.shields.io/npm/dm/dsh-ffmpeg) ![license](https://img.shields.io/npm/l/dsh-ffmpeg) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-ffmpeg?style=social)
3
+ [English](README.en.md)
8
4
 
9
- [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
5
+ ![dsh-ffmpeg 鲸鱼娘插件封面](https://raw.githubusercontent.com/STARDUSTLC666/dsh-ffmpeg/master/assets/cover-whale-girl.png)
10
6
 
7
+ 用自然语言调用 FFmpeg,处理已有音视频文件。
11
8
 
12
- DSH(DeepSeek Harness)视频处理工具插件:十个工具:探测、剪辑、拼接、转码、字幕、提取、GIF、抽帧、调整(变速/音量/静音/旋转)与自检,全部由 ffmpeg/ffprobe 完成。
9
+ [![npm](https://img.shields.io/npm/v/dsh-ffmpeg)](https://www.npmjs.com/package/dsh-ffmpeg) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-ffmpeg-downloads.svg)](https://www.npmjs.com/package/dsh-ffmpeg)
13
10
 
14
- ## 兼容性
11
+ ## 功能
15
12
 
16
- 验证宿主:官方源码构建的 Harness `0.2.0-rc.1`(commit `407e65c8`)+ Node `24.16.0`(2026-09-28)。96 项插件测试在隔离环境全部通过;同一个宿主里 18 个插件共同加载,注册 10 个工具,工具 schema 与健康检查契约通过。本轮未启用真实端口与外部服务。
13
+ - 探测媒体信息,裁剪、拼接和压制视频。
14
+ - 添加字幕、生成 GIF、抽取画面。
15
+ - 通过宿主 subprocess 执行命令。
17
16
 
18
17
  ## 安装
19
18
 
20
- ```bash
21
- dsh plugin --profile web add dsh-ffmpeg
22
- ```
23
-
24
- 需要本机已安装 ffmpeg(`ffmpeg -version` 能出结果即可);不在 PATH 上时可用 `ffmpegPath`/`ffprobePath` 显式指定,或设置环境变量 `DSH_FFMPEG_PATH` / `DSH_FFPROBE_PATH`。
25
-
26
- ## 卸载
19
+ 桌面版可在「插件」面板按包名 `dsh-ffmpeg` 安装。已配置 dsh 命令时也可使用:
27
20
 
28
21
  ```bash
29
- dsh plugin --profile web remove dsh-ffmpeg
22
+ dsh plugin --profile desktop add dsh-ffmpeg
30
23
  ```
31
24
 
32
- 卸载后重启 Web 服务。如需彻底清理,可再手动删除自己 profile `cordis.patch.yml` 中覆盖的插件行。
25
+ 网页版把命令中的 `desktop` 改为 `web`。安装后重启 DSH。
33
26
 
27
+ ## 开始使用
34
28
 
35
- ## 配置
29
+ 准备输入文件后可说:“把这段视频裁成 30 秒,并导出适合分享的 MP4。”
36
30
 
37
- 在你自己的 profile 的 `cordis.patch.yml` 里覆盖本插件行(缺省时全部用默认值):
31
+ ## 依赖与配置
38
32
 
39
- ```yaml
40
- - id: ffmpeg
41
- name: 'dsh-ffmpeg'
42
- config:
43
- # ffmpegPath: C:\tools\ffmpeg\bin\ffmpeg.exe # 非 PATH 时显式指定(也可用 DSH_FFMPEG_PATH)
44
- # ffprobePath: C:\tools\ffmpeg\bin\ffprobe.exe # 也可用 DSH_FFPROBE_PATH
45
- timeoutMs: 300000 # 单次操作超时(默认 5 分钟,10 秒 - 2 小时)
46
- # overwrite: true # 允许覆盖同名输出(默认自动加 _1/_2 序号)
47
- ```
48
-
49
- ## 工具一览
50
-
51
- | 工具 | 作用 | 关键参数 |
52
- | :-- | :-- | :-- |
53
- | `ffmpeg_probe` | 探测媒体信息(格式/时长/分辨率/帧率/码率/音轨/字幕轨;多视频流时返回完整 videos 列表) | `input` 必填 |
54
- | `ffmpeg_cut` | 剪辑片段(默认流拷贝秒级,可精确重编码) | `input` 必填;`start`/`end`/`duration` |
55
- | `ffmpeg_concat` | 拼接 2-20 个片段(同编码流拷贝 / 混合编码重编码;无音轨输入按纯视频拼接) | `inputs` 数组必填 |
56
- | `ffmpeg_encode` | 转码(B 站 1080p/4K、竖屏 1080p、web-720p 预设 + crf/fps/scale 覆盖) | `input` 必填;`preset` 可选 |
57
- | `ffmpeg_subtitle` | 字幕烧录(SRT/ASS 硬字幕) | `input`+`subtitle` 必填 |
58
- | `ffmpeg_extract` | 提取音轨(m4a,非 AAC 自动转 AAC)/ 抽帧序列 / 单帧 / 字幕流 | `input`+`what` 必填 |
59
- | `ffmpeg_gif` | 视频转高质量 GIF(两遍调色板) | `input` 必填;`fps`/`width`/`duration` 可选 |
60
- | `ffmpeg_frames` | 批量抽帧给视觉模型读图(定间隔或指定时间点,PNG/JPG) | `input` 必填;`every`/`times` 二选一 |
61
- | `ffmpeg_adjust` | 变速(音视频同步)/ 音量 / 静音 / 旋转(竖横屏互转),可组合 | `input` 必填;`speed`/`volume`/`mute`/`rotate` 至少一个 |
62
- | `ffmpeg_health` | 自检 ffmpeg / ffprobe 可执行文件 | 无 |
63
-
64
- ### 示例
65
-
66
- ```text
67
- ffmpeg_probe { input: E:\videos\raw.mp4 }
68
- ffmpeg_cut { input: E:\videos\raw.mp4, start: 10, end: 30 }
69
- ffmpeg_encode { input: E:\videos\raw.mp4, preset: bilibili-1080p }
70
- ffmpeg_subtitle { input: E:\videos\raw.mp4, subtitle: E:\videos\subs.srt }
71
- ffmpeg_extract { input: E:\videos\raw.webm, what: audio }
72
- ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
73
- ffmpeg_adjust { input: E:\videos\raw.mp4, volume: +2dB }
74
- ```
75
-
76
- ## 安全设计
33
+ 需要 FFmpeg / ffprobe;可在配置中指定可执行文件路径。
77
34
 
78
- - **无 shell**:所有参数以独立 argv 数组传递,用户输入不可能注入命令
79
- - **进程走 DSH 官方 subprocess 服务**:超时 AbortSignal 会真正触发树级终止(SIGTERM → 强杀,Windows taskkill /T),本插件零运行时依赖
80
- - **防覆写**:默认不覆盖同名文件(自动加序号),输出与输入相同直接拒绝
81
- - **超时钳制**:单次操作 10 秒 - 2 小时;探测额外限制 60 秒
82
- - **参数校验**:时间格式、预设枚举、crf/fps/scale 范围全部前置校验,输入目录、抽帧无扩展名输出等边界也有中文错误/自动修复
35
+ 详细配置、工具参数与排错见[使用说明](docs/USAGE.md)。从源码独立开发时,Node 要求以 [package.json](package.json) 为准。
83
36
 
84
- ## 开发
37
+ ## 文档
85
38
 
86
- ```bash
87
- pnpm install
88
- pnpm test # 构建 + 102 个测试(含真实 ffmpeg 端到端集成,缺 ffmpeg 自动跳过)
89
- ```
39
+ - [使用与排错](docs/USAGE.md)
40
+ - [更新记录](CHANGELOG.md)
41
+ - [验证范围与历史记录](docs/VALIDATION.md)
42
+ - [问题反馈与功能建议](https://github.com/STARDUSTLC666/dsh-ffmpeg/issues)
90
43
 
91
44
  ## License
92
45
 
93
- MIT
94
-
95
- ## 版本记录
96
-
97
- - **0.4.3(2026-09-18)**:修复抽帧数到旧帧/清单截断、取消被当成功、取消文案丢原因、`+2dB` 被拒;音轨提取非 AAC 自动转 AAC、concat 混合音轨可用、ffprobe 截断明确报错。测试 102 项。
46
+ [MIT](LICENSE)
@@ -0,0 +1,13 @@
1
+ # Historical release notes
2
+
3
+ [Current changelog](../CHANGELOG.md) · [Overview](../README.en.md)
4
+
5
+ These English notes preserve the earlier translations. The main changelog contains the consolidated version history.
6
+
7
+ ## 0.4.5 (2026-10-05)
8
+
9
+ - Resolve input, output, subtitles and frame directories against the current DSH session workspace. Reject invalid time arguments before execution.
10
+
11
+ ## 0.4.3 (2026-09-19)
12
+
13
+ - 修复抽帧数到旧帧/清单截断、取消被当成功、取消文案丢原因、`+2dB` 被拒; 音轨提取非 AAC 自动转 AAC、concat 混合音轨可用、ffprobe 截断明确报错. 测试 102 项.
@@ -0,0 +1,79 @@
1
+ # dsh-ffmpeg usage guide
2
+
3
+ [Overview](../README.en.md) · [Changelog](../CHANGELOG.md) · [Validation](VALIDATION.md)
4
+
5
+ ## Current improvements
6
+
7
+ Relative paths resolve against the current session workspace. Explicit start / duration values must be valid; invalid values never silently fall back to full-video processing.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ dsh plugin --profile web add dsh-ffmpeg
13
+ ```
14
+
15
+ ffmpeg must be installed locally (`ffmpeg -version` should work); use `ffmpegPath` / `ffprobePath`, or the `DSH_FFMPEG_PATH` / `DSH_FFPROBE_PATH` environment variables, when it is not on PATH.
16
+
17
+ ## Uninstall
18
+
19
+ ```bash
20
+ dsh plugin --profile web remove dsh-ffmpeg
21
+ ```
22
+
23
+ Then restart the web service. To clean up fully, also remove the plugin entry from your profile `cordis.patch.yml` if you overrode it.
24
+
25
+ ## Configuration
26
+
27
+ Override the plugin row in your profile's `cordis.patch.yml` (defaults apply when absent):
28
+
29
+ ```yaml
30
+ - id: ffmpeg
31
+ name: 'dsh-ffmpeg'
32
+ config:
33
+ # ffmpegPath: C:\tools\ffmpeg\bin\ffmpeg.exe # explicit path (or DSH_FFMPEG_PATH)
34
+ # ffprobePath: C:\tools\ffmpeg\bin\ffprobe.exe # or DSH_FFPROBE_PATH
35
+ timeoutMs: 300000 # per-operation timeout (default 5 min, 10s - 2h)
36
+ # overwrite: true # allow overwriting outputs (default auto-suffix _1/_2)
37
+ ```
38
+
39
+ ## Tools
40
+
41
+ | Tool | Purpose | Key parameters |
42
+ | :-- | :-- | :-- |
43
+ | `ffmpeg_probe` | Probe media info (format/duration/resolution/fps/bitrate/audio/subtitle streams; multi-video files return a full videos list) | `input` required |
44
+ | `ffmpeg_cut` | Cut a clip (stream copy by default, accurate re-encode optional) | `input` required; `start`/`end`/`duration` |
45
+ | `ffmpeg_concat` | Concatenate 2-20 clips (stream copy for identical codecs / re-encode for mixed; video-only when any input has no audio) | `inputs` array required |
46
+ | `ffmpeg_encode` | Transcode with presets (bilibili 1080p/4K, vertical 1080p, web-720p) plus crf/fps/scale overrides | `input` required; `preset` optional |
47
+ | `ffmpeg_subtitle` | Burn subtitles (SRT/ASS hard subs) | `input` + `subtitle` required |
48
+ | `ffmpeg_extract` | Extract audio (m4a; non-AAC is auto-transcoded to AAC) / frame sequences / single frame / subtitle stream | `input` + `what` required |
49
+ | `ffmpeg_gif` | Video to high-quality GIF (two-pass palette) | `input` required; `fps`/`width`/`duration` optional |
50
+
51
+ ### Examples
52
+
53
+ ```text
54
+ ffmpeg_probe { input: E:\videos\raw.mp4 }
55
+ ffmpeg_cut { input: E:\videos\raw.mp4, start: 10, end: 30 }
56
+ ffmpeg_encode { input: E:\videos\raw.mp4, preset: bilibili-1080p }
57
+ ffmpeg_subtitle { input: E:\videos\raw.mp4, subtitle: E:\videos\subs.srt }
58
+ ffmpeg_extract { input: E:\videos\raw.webm, what: audio }
59
+ ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
60
+ ```
61
+
62
+ ## Safety
63
+
64
+ - **No shell**: every argument is passed as its own argv element — user input cannot inject commands
65
+ - **Runs on the official DSH subprocess service**: the timeout AbortSignal now really drives tree-scoped termination (SIGTERM → kill; taskkill /T on Windows), zero runtime dependencies
66
+ - **No accidental overwrites**: existing outputs get auto-suffixed; output == input is rejected
67
+ - **Timeout clamps**: per-operation 10s - 2h; probes additionally capped at 60s
68
+ - **Input validation**: time formats, preset enums, crf/fps/scale ranges are validated up front; directory inputs are rejected and extension-less frame-sequence outputs are auto-fixed
69
+
70
+ ## Development
71
+
72
+ ```bash
73
+ pnpm install
74
+ pnpm test # build + 104 tests
75
+ ```
76
+
77
+ ## License
78
+
79
+ MIT
package/docs/USAGE.md ADDED
@@ -0,0 +1,83 @@
1
+ # dsh-ffmpeg 使用说明
2
+
3
+ [返回简介](../README.md) · [更新记录](../CHANGELOG.md) · [验证记录](VALIDATION.md)
4
+
5
+ ## 本次改进
6
+
7
+ 相对文件路径以当前会话工作区为基准。提供 start / duration 时必须是合法时间,非法输入不会退回默认值或意外处理整段视频。
8
+
9
+ ## 安装
10
+
11
+ ```bash
12
+ dsh plugin --profile web add dsh-ffmpeg
13
+ ```
14
+
15
+ 需要本机已安装 ffmpeg(`ffmpeg -version` 能出结果即可);不在 PATH 上时可用 `ffmpegPath`/`ffprobePath` 显式指定,或设置环境变量 `DSH_FFMPEG_PATH` / `DSH_FFPROBE_PATH`。
16
+
17
+ ## 卸载
18
+
19
+ ```bash
20
+ dsh plugin --profile web remove dsh-ffmpeg
21
+ ```
22
+
23
+ 卸载后重启 Web 服务。如需彻底清理,可再手动删除自己 profile `cordis.patch.yml` 中覆盖的插件行。
24
+
25
+ ## 配置
26
+
27
+ 在你自己的 profile 的 `cordis.patch.yml` 里覆盖本插件行(缺省时全部用默认值):
28
+
29
+ ```yaml
30
+ - id: ffmpeg
31
+ name: 'dsh-ffmpeg'
32
+ config:
33
+ # ffmpegPath: C:\tools\ffmpeg\bin\ffmpeg.exe # 非 PATH 时显式指定(也可用 DSH_FFMPEG_PATH)
34
+ # ffprobePath: C:\tools\ffmpeg\bin\ffprobe.exe # 也可用 DSH_FFPROBE_PATH
35
+ timeoutMs: 300000 # 单次操作超时(默认 5 分钟,10 秒 - 2 小时)
36
+ # overwrite: true # 允许覆盖同名输出(默认自动加 _1/_2 序号)
37
+ ```
38
+
39
+ ## 工具一览
40
+
41
+ | 工具 | 作用 | 关键参数 |
42
+ | :-- | :-- | :-- |
43
+ | `ffmpeg_probe` | 探测媒体信息(格式/时长/分辨率/帧率/码率/音轨/字幕轨;多视频流时返回完整 videos 列表) | `input` 必填 |
44
+ | `ffmpeg_cut` | 剪辑片段(默认流拷贝秒级,可精确重编码) | `input` 必填;`start`/`end`/`duration` |
45
+ | `ffmpeg_concat` | 拼接 2-20 个片段(同编码流拷贝 / 混合编码重编码;无音轨输入按纯视频拼接) | `inputs` 数组必填 |
46
+ | `ffmpeg_encode` | 转码(B 站 1080p/4K、竖屏 1080p、web-720p 预设 + crf/fps/scale 覆盖) | `input` 必填;`preset` 可选 |
47
+ | `ffmpeg_subtitle` | 字幕烧录(SRT/ASS 硬字幕) | `input`+`subtitle` 必填 |
48
+ | `ffmpeg_extract` | 提取音轨(m4a,非 AAC 自动转 AAC)/ 抽帧序列 / 单帧 / 字幕流 | `input`+`what` 必填 |
49
+ | `ffmpeg_gif` | 视频转高质量 GIF(两遍调色板) | `input` 必填;`fps`/`width`/`duration` 可选 |
50
+ | `ffmpeg_frames` | 批量抽帧给视觉模型读图(定间隔或指定时间点,PNG/JPG) | `input` 必填;`every`/`times` 二选一 |
51
+ | `ffmpeg_adjust` | 变速(音视频同步)/ 音量 / 静音 / 旋转(竖横屏互转),可组合 | `input` 必填;`speed`/`volume`/`mute`/`rotate` 至少一个 |
52
+ | `ffmpeg_health` | 自检 ffmpeg / ffprobe 可执行文件 | 无 |
53
+
54
+ ### 示例
55
+
56
+ ```text
57
+ ffmpeg_probe { input: E:\videos\raw.mp4 }
58
+ ffmpeg_cut { input: E:\videos\raw.mp4, start: 10, end: 30 }
59
+ ffmpeg_encode { input: E:\videos\raw.mp4, preset: bilibili-1080p }
60
+ ffmpeg_subtitle { input: E:\videos\raw.mp4, subtitle: E:\videos\subs.srt }
61
+ ffmpeg_extract { input: E:\videos\raw.webm, what: audio }
62
+ ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
63
+ ffmpeg_adjust { input: E:\videos\raw.mp4, volume: +2dB }
64
+ ```
65
+
66
+ ## 安全设计
67
+
68
+ - **无 shell**:所有参数以独立 argv 数组传递,用户输入不可能注入命令
69
+ - **进程走 DSH 官方 subprocess 服务**:超时 AbortSignal 会真正触发树级终止(SIGTERM → 强杀,Windows taskkill /T),本插件零运行时依赖
70
+ - **防覆写**:默认不覆盖同名文件(自动加序号),输出与输入相同直接拒绝
71
+ - **超时钳制**:单次操作 10 秒 - 2 小时;探测额外限制 60 秒
72
+ - **参数校验**:时间格式、预设枚举、crf/fps/scale 范围全部前置校验,输入目录、抽帧无扩展名输出等边界也有中文错误/自动修复
73
+
74
+ ## 开发
75
+
76
+ ```bash
77
+ pnpm install
78
+ pnpm test # 构建 + 104 个测试
79
+ ```
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,13 @@
1
+ # dsh-ffmpeg 验证记录
2
+
3
+ 本页整理原 README 的历史验证说明,保留当时的版本、日期与范围。自动测试、启动检查、浏览器操作和真实服务验收分别记录,不能相互替代。更详细的版本验收文件仍保留在仓库中。
4
+
5
+ ## 原中文记录
6
+
7
+ 验证宿主:官方源码构建的 Harness `0.2.0-rc.1`(commit `407e65c8`)+ Node `24.16.0`(2026-09-28)。96 项插件测试在隔离环境全部通过;同一个宿主里 18 个插件共同加载,注册 10 个工具,工具 schema 与健康检查契约通过。本轮未启用真实端口与外部服务。
8
+
9
+ ## Original English record
10
+
11
+ Validation host: Harness `0.2.0-rc.1` built from official sources (commit `407e65c8`) with Node `24.16.0` on 2026-09-28. All 96 plugin tests pass in an isolated environment; all 18 plugins mount together in one host registering 10 tools, with tool schemas and health-check contracts passing. No live ports or external services were exercised in this round.
12
+
13
+ 2026-09-13 fix: retain the service receiver when calling `subprocess.spawn`, preventing failures caused by passing the method as an unbound callback. Verified against a real isolated host subprocess service. FFmpeg/ffprobe version checks and probing a real MP4 pass. This fixes the tool invocation failure reported in [#3](https://github.com/STARDUSTLC666/dsh-ffmpeg/issues/3).
package/lib/paths.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /** 校验输入文件存在且是文件;返回绝对路径。 */
2
- export declare function assertInputFile(input: string): string;
2
+ export declare function assertInputFile(input: string, cwd?: string): string;
3
3
  /** 清洗文件名中的危险字符。 */
4
4
  export declare function sanitizeName(name: string): string;
5
5
  /**
6
6
  * 决定输出路径:缺省时放在输入同目录,名字 = 输入名 + suffix + ext。
7
7
  * 目标已存在且不允许覆写时自动追加 _1/_2…。
8
8
  */
9
- export declare function resolveOutputPath(input: string, explicit: string | undefined, suffix: string, ext: string, overwrite: boolean): string;
9
+ export declare function resolveOutputPath(input: string, explicit: string | undefined, suffix: string, ext: string, overwrite: boolean, cwd?: string): string;
package/lib/paths.js CHANGED
@@ -6,8 +6,8 @@
6
6
  import { existsSync, statSync } from 'node:fs';
7
7
  import { basename, dirname, extname, join, resolve } from 'node:path';
8
8
  /** 校验输入文件存在且是文件;返回绝对路径。 */
9
- export function assertInputFile(input) {
10
- const absolute = resolve(input);
9
+ export function assertInputFile(input, cwd = process.cwd()) {
10
+ const absolute = resolve(cwd, input);
11
11
  if (!existsSync(absolute)) {
12
12
  throw new Error('输入文件不存在:' + input);
13
13
  }
@@ -25,10 +25,10 @@ export function sanitizeName(name) {
25
25
  * 决定输出路径:缺省时放在输入同目录,名字 = 输入名 + suffix + ext。
26
26
  * 目标已存在且不允许覆写时自动追加 _1/_2…。
27
27
  */
28
- export function resolveOutputPath(input, explicit, suffix, ext, overwrite) {
28
+ export function resolveOutputPath(input, explicit, suffix, ext, overwrite, cwd = process.cwd()) {
29
29
  let target;
30
30
  if (explicit !== undefined && explicit.trim() !== '') {
31
- target = resolve(explicit.trim());
31
+ target = resolve(cwd, explicit.trim());
32
32
  }
33
33
  else {
34
34
  const base = basename(input, extname(input));
package/lib/tools.d.ts CHANGED
@@ -15,6 +15,13 @@ export interface ContentBlock {
15
15
  /** v0.1.2-rc.1 工具执行上下文中本插件需要的公共最小面。 */
16
16
  export interface FfmpegToolRunContext {
17
17
  readonly signal: AbortSignal;
18
+ readonly agent?: {
19
+ session?: {
20
+ header?: {
21
+ cwd?: string;
22
+ };
23
+ };
24
+ };
18
25
  }
19
26
  /** 注册给 ctx.tools.register 的原始工具定义。 */
20
27
  export interface FfmpegToolDefinition {
package/lib/tools.js CHANGED
@@ -7,11 +7,12 @@
7
7
  import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { tmpdir } from 'node:os';
10
- import { basename, dirname, extname, join } from 'node:path';
10
+ import { basename, dirname, extname, join, resolve } from 'node:path';
11
11
  import { adjustArgs, concatArgs, concatListContent, cutArgs, ENCODE_PRESETS, encodeArgs, extractArgs, fmtSeconds, frameAtArgs, gifPaletteArgs, gifUseArgs, probeArgs, subtitleArgs, } from './args.js';
12
12
  import { parseProbeJson } from './ffprobe.js';
13
13
  import { assertInputFile, resolveOutputPath, sanitizeName } from './paths.js';
14
14
  import { parseTimeArg } from './config.js';
15
+ function executionCwd(exec) { return exec?.agent?.session?.header?.cwd || process.cwd(); }
15
16
  /** 编译作者 DSL 为原始 JSON Schema(正是 defineTool 存为 definition.parameters 的值)。 */
16
17
  function compileParameters(spec) {
17
18
  const properties = {};
@@ -55,8 +56,12 @@ function requiredTime(args, key, label) {
55
56
  return value;
56
57
  }
57
58
  function optionalTime(args, key) {
59
+ if (args[key] === undefined)
60
+ return undefined;
58
61
  const value = parseTimeArg(args[key]);
59
- return value === null ? undefined : value;
62
+ if (value === null)
63
+ throw new Error(key + ' 非法:请用非负秒数或 HH:MM:SS[.mmm] 格式。');
64
+ return value;
60
65
  }
61
66
  function stringArray(args, key) {
62
67
  const value = args[key];
@@ -188,7 +193,7 @@ export function buildFfmpegTools(config, runner) {
188
193
  },
189
194
  async execute(rawArgs, exec) {
190
195
  const args = asRecord(rawArgs);
191
- const input = assertInputFile(requiredString(args, 'input', '输入文件'));
196
+ const input = assertInputFile(requiredString(args, 'input', '输入文件'), executionCwd(exec));
192
197
  const result = await runChecked(runner, probeArgs(cfg.ffprobePath, input), Math.min(timeout, 60000), 'ffprobe', exec?.signal);
193
198
  const media = parseProbeJson(result.stdout);
194
199
  return { ok: true, input, summary: buildProbeSummary(media), ...media };
@@ -215,7 +220,7 @@ export function buildFfmpegTools(config, runner) {
215
220
  },
216
221
  async execute(rawArgs, exec) {
217
222
  const args = asRecord(rawArgs);
218
- const input = assertInputFile(requiredString(args, 'input', '输入文件'));
223
+ const input = assertInputFile(requiredString(args, 'input', '输入文件'), executionCwd(exec));
219
224
  const start = optionalTime(args, 'start') ?? 0;
220
225
  const end = optionalTime(args, 'end');
221
226
  let duration;
@@ -230,7 +235,7 @@ export function buildFfmpegTools(config, runner) {
230
235
  throw new Error('duration 必须大于 0。');
231
236
  }
232
237
  const reencode = args.reencode === true;
233
- const output = resolveOutputPath(input, optionalString(args, 'output'), '.cut', extname(input) || '.mp4', cfg.overwrite);
238
+ const output = resolveOutputPath(input, optionalString(args, 'output'), '.cut', extname(input) || '.mp4', cfg.overwrite, executionCwd(exec));
234
239
  await runChecked(runner, cutArgs(cfg.ffmpegPath, { input, start, duration, output, overwrite: cfg.overwrite, reencode }), timeout, 'ffmpeg 剪辑', exec?.signal);
235
240
  return { output, start, duration, reencode };
236
241
  },
@@ -258,10 +263,10 @@ export function buildFfmpegTools(config, runner) {
258
263
  throw new Error('inputs 至少需要 2 个文件(当前 ' + inputs.length + ' 个)。');
259
264
  if (inputs.length > 20)
260
265
  throw new Error('inputs 最多 20 个文件(当前 ' + inputs.length + ' 个)。');
261
- const absolute = inputs.map(assertInputFile);
266
+ const absolute = inputs.map(input => assertInputFile(input, executionCwd(exec)));
262
267
  const reencode = args.reencode === true;
263
268
  const firstExt = extname(absolute[0]) || '.mp4';
264
- const output = resolveOutputPath(absolute[0], optionalString(args, 'output'), '.concat', firstExt, cfg.overwrite);
269
+ const output = resolveOutputPath(absolute[0], optionalString(args, 'output'), '.concat', firstExt, cfg.overwrite, executionCwd(exec));
265
270
  if (!reencode) {
266
271
  const listPath = join(tmpdir(), 'dsh-ffmpeg-concat-' + Date.now() + '-' + randomUUID().slice(0, 8) + '.txt');
267
272
  writeFileSync(listPath, concatListContent(absolute), 'utf8');
@@ -308,7 +313,7 @@ export function buildFfmpegTools(config, runner) {
308
313
  },
309
314
  async execute(rawArgs, exec) {
310
315
  const args = asRecord(rawArgs);
311
- const input = assertInputFile(requiredString(args, 'input', '输入文件'));
316
+ const input = assertInputFile(requiredString(args, 'input', '输入文件'), executionCwd(exec));
312
317
  const presetRaw = optionalString(args, 'preset') ?? 'bilibili-1080p';
313
318
  if (!ENCODE_PRESETS.includes(presetRaw)) {
314
319
  throw new Error('preset 必须是 ' + ENCODE_PRESETS.join(' / ') + ' 之一(当前:' + presetRaw + ')。');
@@ -335,7 +340,7 @@ export function buildFfmpegTools(config, runner) {
335
340
  throw new Error('scale 格式必须是 宽:高,如 1920:1080 或 -2:720。');
336
341
  scale = scaleRaw;
337
342
  }
338
- const output = resolveOutputPath(input, optionalString(args, 'output'), '.encoded', extname(input) || '.mp4', cfg.overwrite);
343
+ const output = resolveOutputPath(input, optionalString(args, 'output'), '.encoded', extname(input) || '.mp4', cfg.overwrite, executionCwd(exec));
339
344
  await runChecked(runner, encodeArgs(cfg.ffmpegPath, { input, output, preset, crf, fps, scale, overwrite: cfg.overwrite }), timeout, 'ffmpeg 转码', exec?.signal);
340
345
  return { output, preset, crf: crf ?? 'preset', fps: fps ?? null, scale: scale ?? null };
341
346
  },
@@ -358,9 +363,9 @@ export function buildFfmpegTools(config, runner) {
358
363
  },
359
364
  async execute(rawArgs, exec) {
360
365
  const args = asRecord(rawArgs);
361
- const input = assertInputFile(requiredString(args, 'input', '输入视频'));
362
- const subtitlePath = assertInputFile(requiredString(args, 'subtitle', '字幕文件'));
363
- const output = resolveOutputPath(input, optionalString(args, 'output'), '.sub', extname(input) || '.mp4', cfg.overwrite);
366
+ const input = assertInputFile(requiredString(args, 'input', '输入视频'), executionCwd(exec));
367
+ const subtitlePath = assertInputFile(requiredString(args, 'subtitle', '字幕文件'), executionCwd(exec));
368
+ const output = resolveOutputPath(input, optionalString(args, 'output'), '.sub', extname(input) || '.mp4', cfg.overwrite, executionCwd(exec));
364
369
  await runChecked(runner, subtitleArgs(cfg.ffmpegPath, { input, subtitle: subtitlePath, output, overwrite: cfg.overwrite }), timeout, 'ffmpeg 字幕', exec?.signal);
365
370
  return { output, mode: 'burn' };
366
371
  },
@@ -387,7 +392,7 @@ export function buildFfmpegTools(config, runner) {
387
392
  },
388
393
  async execute(rawArgs, exec) {
389
394
  const args = asRecord(rawArgs);
390
- const input = assertInputFile(requiredString(args, 'input', '输入文件'));
395
+ const input = assertInputFile(requiredString(args, 'input', '输入文件'), executionCwd(exec));
391
396
  const what = requiredString(args, 'what', '提取内容');
392
397
  const allowed = ['audio', 'frames', 'frame', 'subtitle'];
393
398
  if (!allowed.includes(what))
@@ -398,16 +403,17 @@ export function buildFfmpegTools(config, runner) {
398
403
  const streamIndex = typeof args.streamIndex === 'number' && Number.isInteger(args.streamIndex) && args.streamIndex >= 0 ? args.streamIndex : 0;
399
404
  let output;
400
405
  if (what === 'audio') {
401
- output = resolveOutputPath(input, optionalString(args, 'output'), '.audio', '.m4a', cfg.overwrite);
406
+ output = resolveOutputPath(input, optionalString(args, 'output'), '.audio', '.m4a', cfg.overwrite, executionCwd(exec));
402
407
  }
403
408
  else if (what === 'frame') {
404
- output = resolveOutputPath(input, optionalString(args, 'output'), '.frame', '.png', cfg.overwrite);
409
+ output = resolveOutputPath(input, optionalString(args, 'output'), '.frame', '.png', cfg.overwrite, executionCwd(exec));
405
410
  }
406
411
  else if (what === 'subtitle') {
407
- output = resolveOutputPath(input, optionalString(args, 'output'), '.subtitle', '.srt', cfg.overwrite);
412
+ output = resolveOutputPath(input, optionalString(args, 'output'), '.subtitle', '.srt', cfg.overwrite, executionCwd(exec));
408
413
  }
409
414
  else {
410
- const explicit = optionalString(args, 'output');
415
+ const rawOutput = optionalString(args, 'output');
416
+ const explicit = rawOutput === undefined ? undefined : resolve(executionCwd(exec), rawOutput);
411
417
  if (explicit !== undefined) {
412
418
  if (explicit.includes('%')) {
413
419
  output = explicit;
@@ -463,7 +469,7 @@ export function buildFfmpegTools(config, runner) {
463
469
  },
464
470
  async execute(rawArgs, exec) {
465
471
  const args = asRecord(rawArgs);
466
- const input = assertInputFile(requiredString(args, 'input', '输入视频'));
472
+ const input = assertInputFile(requiredString(args, 'input', '输入视频'), executionCwd(exec));
467
473
  const start = optionalTime(args, 'start') ?? 0;
468
474
  const duration = optionalTime(args, 'duration') ?? 10;
469
475
  if (duration <= 0)
@@ -472,7 +478,7 @@ export function buildFfmpegTools(config, runner) {
472
478
  const fps = typeof fpsRaw === 'number' && Number.isInteger(fpsRaw) ? Math.min(30, Math.max(1, fpsRaw)) : 10;
473
479
  const widthRaw = args.width;
474
480
  const width = typeof widthRaw === 'number' && Number.isInteger(widthRaw) ? Math.min(1280, Math.max(64, widthRaw)) : 480;
475
- const output = resolveOutputPath(input, optionalString(args, 'output'), '.gif', '.gif', cfg.overwrite);
481
+ const output = resolveOutputPath(input, optionalString(args, 'output'), '.gif', '.gif', cfg.overwrite, executionCwd(exec));
476
482
  // 调色板属于内部临时产物,不能借用用户输出旁的可预测路径;否则 -y 与
477
483
  // finally 清理都可能覆盖/删除用户原有的 <output>.palette.png。
478
484
  const paletteDir = mkdtempSync(join(tmpdir(), 'dsh-ffmpeg-gif-'));
@@ -509,13 +515,13 @@ export function buildFfmpegTools(config, runner) {
509
515
  },
510
516
  async execute(rawArgs, exec) {
511
517
  const args = asRecord(rawArgs);
512
- const input = assertInputFile(requiredString(args, 'input', '输入文件'));
518
+ const input = assertInputFile(requiredString(args, 'input', '输入文件'), executionCwd(exec));
513
519
  const formatRaw = optionalString(args, 'format')?.toLowerCase() ?? 'png';
514
520
  if (formatRaw !== 'png' && formatRaw !== 'jpg' && formatRaw !== 'jpeg')
515
521
  throw new Error('format 只支持 png 或 jpg。');
516
522
  const ext = formatRaw === 'png' ? '.png' : '.jpg';
517
523
  const times = stringArray(args, 'times');
518
- const outDir = optionalString(args, 'outputDir') ?? join(dirname(input), sanitizeName(basename(input, extname(input))) + '-frames');
524
+ const outDir = resolve(executionCwd(exec), optionalString(args, 'outputDir') ?? join(dirname(input), sanitizeName(basename(input, extname(input))) + '-frames'));
519
525
  mkdirSync(outDir, { recursive: true });
520
526
  // 每次运行独占 run-* 子目录:历史 frame-* 不参与计数,也不会被本次 -y 覆盖
521
527
  const runDir = mkdtempSync(join(outDir, 'run-'));
@@ -568,7 +574,7 @@ export function buildFfmpegTools(config, runner) {
568
574
  },
569
575
  async execute(rawArgs, exec) {
570
576
  const args = asRecord(rawArgs);
571
- const input = assertInputFile(requiredString(args, 'input', '输入文件'));
577
+ const input = assertInputFile(requiredString(args, 'input', '输入文件'), executionCwd(exec));
572
578
  const speed = optionalNumber(args, 'speed');
573
579
  if (speed !== undefined && (speed < 0.1 || speed > 100))
574
580
  throw new Error('speed 必须在 0.1-100 之间(当前:' + speed + ')。');
@@ -590,7 +596,7 @@ export function buildFfmpegTools(config, runner) {
590
596
  // 先探测:确认有没有音轨,避免对无声文件构建音频滤镜报错
591
597
  const probeResult = await runChecked(runner, probeArgs(cfg.ffprobePath, input), Math.min(timeout, 60000), 'ffprobe', exec?.signal);
592
598
  const hasAudio = parseProbeJson(probeResult.stdout).audio.length > 0;
593
- const output = resolveOutputPath(input, optionalString(args, 'output'), '.adjust', extname(input) || '.mp4', cfg.overwrite);
599
+ const output = resolveOutputPath(input, optionalString(args, 'output'), '.adjust', extname(input) || '.mp4', cfg.overwrite, executionCwd(exec));
594
600
  await runChecked(runner, adjustArgs(cfg.ffmpegPath, { input, output, overwrite: cfg.overwrite, speed, volume, mute, rotate, hasAudio }), timeout, 'ffmpeg 调整', exec?.signal);
595
601
  const ops = [];
596
602
  if (speed !== undefined)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-ffmpeg",
3
- "version": "0.4.4",
4
- "description": "DSH 视频处理插件:ffmpeg 探测、裁剪、拼接、压制、字幕、GIF、抽帧与调整,走官方 subprocess 服务,零运行时依赖。",
3
+ "version": "0.4.5",
4
+ "description": "用自然语言调用 FFmpeg,处理已有音视频文件。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -17,7 +17,9 @@
17
17
  "lib",
18
18
  "cordis.patch.yml",
19
19
  "README.md",
20
- "README.en.md"
20
+ "README.en.md",
21
+ "docs",
22
+ "CHANGELOG.md"
21
23
  ],
22
24
  "scripts": {
23
25
  "build": "tsc -p tsconfig.json",