dsh-ffmpeg 0.4.4 → 0.5.0

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,27 @@
1
+ # 更新记录
2
+
3
+ [返回简介](README.md) · [使用说明](docs/USAGE.md) · [验证记录](docs/VALIDATION.md)
4
+
5
+ [历史英文记录](docs/CHANGELOG.en.md)
6
+
7
+ ## 0.5.0 (2026-10-15)
8
+
9
+ - 修复 GIF 的起点和时长选项作用到错误输入,调色板以单帧写入;非零起点也能按指定区间导出。
10
+ - 重编码拼接先统一画布、帧率、像素比例与时间戳,可指定目标宽高和帧率,等比补边。
11
+ - 混合有声/无声素材时只为无声片段补静音,保留其余片段的声音;全无声素材保持无音轨。
12
+
13
+ ## 0.4.5 (2026-10-05)
14
+
15
+ - 输入、输出、字幕与抽帧路径按当前 DSH 会话工作区解析,避免宿主启动目录造成找错文件;非法时间参数立即报错。
16
+
17
+ ## 0.4.4 (2026-09-28)
18
+
19
+ - 更新官方 Harness 0.2.0-rc.1 的兼容声明和共同加载验证;运行时代码未变。验证范围见[验证记录](docs/VALIDATION.md)。
20
+
21
+ ## 0.4.3 (2026-09-19)
22
+
23
+ - 修复抽帧数到旧帧/清单截断、取消被当成功、取消文案丢原因、`+2dB` 被拒;音轨提取非 AAC 自动转 AAC、concat 混合音轨可用、ffprobe 截断明确报错。测试 102 项。
24
+
25
+ ## 更早的改动
26
+
27
+ 完整历史可查阅 [GitHub 提交记录](https://github.com/STARDUSTLC666/dsh-ffmpeg/commits/master)。
package/README.en.md CHANGED
@@ -1,92 +1,49 @@
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)
3
+ [中文](README.md)
8
4
 
9
- DSH (DeepSeek Harness) video-processing plugin: seven tools covering probing, cutting, concatenation, transcoding, subtitles, extraction and GIF creation — all powered by ffmpeg/ffprobe.
5
+ ![dsh-ffmpeg whale girl plugin cover](https://raw.githubusercontent.com/STARDUSTLC666/dsh-ffmpeg/master/assets/cover-whale-girl.png)
10
6
 
11
- ## Compatibility
7
+ Use FFmpeg to process existing audio and video files through natural-language requests.
12
8
 
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.
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)
14
10
 
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).
11
+ Feedback and contributions are welcome: report [issues](https://github.com/STARDUSTLC666/dsh-ffmpeg/issues) or submit [pull requests](https://github.com/STARDUSTLC666/dsh-ffmpeg/pulls).
16
12
 
17
- ## Installation
13
+ ## What it does
18
14
 
19
- ```bash
20
- dsh plugin --profile web add dsh-ffmpeg
21
- ```
15
+ - Inspect media and trim, join or transcode video.
16
+ - Fit clips with different sizes and frame rates to one canvas; silent clips receive silence without muting the other clips.
17
+ - Add subtitles, create GIFs and extract frames.
18
+ - Execute commands through the host subprocess service.
22
19
 
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.
20
+ ## Install
24
21
 
25
- ## Uninstall
22
+ In DSH Desktop, install `dsh-ffmpeg` from the Plugins panel. If the bundled dsh command is available:
26
23
 
27
24
  ```bash
28
- dsh plugin --profile web remove dsh-ffmpeg
25
+ dsh plugin --profile desktop add dsh-ffmpeg
29
26
  ```
30
27
 
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.
32
-
28
+ For the web version, replace `desktop` with `web`. Restart DSH after installation.
33
29
 
34
- ## Configuration
30
+ ## Start using it
35
31
 
36
- Override the plugin row in your profile's `cordis.patch.yml` (defaults apply when absent):
32
+ Provide an input file and ask: “Trim this video to 30 seconds and export a shareable MP4.”
37
33
 
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
- ```
34
+ ## Requirements and configuration
47
35
 
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
- ```
36
+ Requires FFmpeg and ffprobe. Executable paths can be configured.
70
37
 
71
- ## Safety
38
+ 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
39
 
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
40
+ ## Documentation
78
41
 
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
- ```
42
+ - [Usage and troubleshooting](docs/USAGE.en.md)
43
+ - [Changelog](CHANGELOG.md)
44
+ - [Validation scope and history](docs/VALIDATION.md)
45
+ - [Report a problem or suggest a feature](https://github.com/STARDUSTLC666/dsh-ffmpeg/issues)
85
46
 
86
47
  ## License
87
48
 
88
- MIT
89
-
90
- ## Changelog
91
-
92
- - **0.4.3 (2026-09-18)**: 修复抽帧数到旧帧/清单截断、取消被当成功、取消文案丢原因、`+2dB` 被拒; 音轨提取非 AAC 自动转 AAC、concat 混合音轨可用、ffprobe 截断明确报错. 测试 102 项.
49
+ [MIT](LICENSE)
package/README.md CHANGED
@@ -1,97 +1,49 @@
1
- [English](README.en.md)
2
-
3
1
  # dsh-ffmpeg
4
2
 
5
- > **你的 agent 会剪视频了**:探测/剪辑/拼接/转码/字幕/抽帧/GIF/变速旋转调音,十个工具一条命令。
3
+ [English](README.en.md)
6
4
 
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)
5
+ ![dsh-ffmpeg 鲸鱼娘插件封面](https://raw.githubusercontent.com/STARDUSTLC666/dsh-ffmpeg/master/assets/cover-whale-girl.png)
8
6
 
9
- [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
7
+ 用自然语言调用 FFmpeg,处理已有音视频文件。
10
8
 
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)
11
10
 
12
- DSH(DeepSeek Harness)视频处理工具插件:十个工具:探测、剪辑、拼接、转码、字幕、提取、GIF、抽帧、调整(变速/音量/静音/旋转)与自检,全部由 ffmpeg/ffprobe 完成。
11
+ 欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-ffmpeg/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-ffmpeg/pulls)。
13
12
 
14
- ## 兼容性
13
+ ## 功能
15
14
 
16
- 验证宿主:官方源码构建的 Harness `0.2.0-rc.1`(commit `407e65c8`)+ Node `24.16.0`(2026-09-28)。96 项插件测试在隔离环境全部通过;同一个宿主里 18 个插件共同加载,注册 10 个工具,工具 schema 与健康检查契约通过。本轮未启用真实端口与外部服务。
15
+ - 探测媒体信息,裁剪、拼接和压制视频。
16
+ - 拼接不同尺寸和帧率的素材时可统一画布并等比补边;无声片段补静音,保留其余原声。
17
+ - 添加字幕、生成 GIF、抽取画面。
18
+ - 通过宿主 subprocess 执行命令。
17
19
 
18
20
  ## 安装
19
21
 
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
- ## 卸载
22
+ 桌面版可在「插件」面板按包名 `dsh-ffmpeg` 安装。已配置 dsh 命令时也可使用:
27
23
 
28
24
  ```bash
29
- dsh plugin --profile web remove dsh-ffmpeg
25
+ dsh plugin --profile desktop add dsh-ffmpeg
30
26
  ```
31
27
 
32
- 卸载后重启 Web 服务。如需彻底清理,可再手动删除自己 profile `cordis.patch.yml` 中覆盖的插件行。
28
+ 网页版把命令中的 `desktop` 改为 `web`。安装后重启 DSH。
33
29
 
30
+ ## 开始使用
34
31
 
35
- ## 配置
32
+ 准备输入文件后可说:“把这段视频裁成 30 秒,并导出适合分享的 MP4。”
36
33
 
37
- 在你自己的 profile 的 `cordis.patch.yml` 里覆盖本插件行(缺省时全部用默认值):
34
+ ## 依赖与配置
38
35
 
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
- ```
36
+ 需要 FFmpeg / ffprobe;可在配置中指定可执行文件路径。
48
37
 
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
- ## 安全设计
38
+ 详细配置、工具参数与排错见[使用说明](docs/USAGE.md)。从源码独立开发时,Node 要求以 [package.json](package.json) 为准。
77
39
 
78
- - **无 shell**:所有参数以独立 argv 数组传递,用户输入不可能注入命令
79
- - **进程走 DSH 官方 subprocess 服务**:超时 AbortSignal 会真正触发树级终止(SIGTERM → 强杀,Windows taskkill /T),本插件零运行时依赖
80
- - **防覆写**:默认不覆盖同名文件(自动加序号),输出与输入相同直接拒绝
81
- - **超时钳制**:单次操作 10 秒 - 2 小时;探测额外限制 60 秒
82
- - **参数校验**:时间格式、预设枚举、crf/fps/scale 范围全部前置校验,输入目录、抽帧无扩展名输出等边界也有中文错误/自动修复
40
+ ## 文档
83
41
 
84
- ## 开发
85
-
86
- ```bash
87
- pnpm install
88
- pnpm test # 构建 + 102 个测试(含真实 ffmpeg 端到端集成,缺 ffmpeg 自动跳过)
89
- ```
42
+ - [使用与排错](docs/USAGE.md)
43
+ - [更新记录](CHANGELOG.md)
44
+ - [验证范围与历史记录](docs/VALIDATION.md)
45
+ - [问题反馈与功能建议](https://github.com/STARDUSTLC666/dsh-ffmpeg/issues)
90
46
 
91
47
  ## License
92
48
 
93
- MIT
94
-
95
- ## 版本记录
96
-
97
- - **0.4.3(2026-09-18)**:修复抽帧数到旧帧/清单截断、取消被当成功、取消文案丢原因、`+2dB` 被拒;音轨提取非 AAC 自动转 AAC、concat 混合音轨可用、ffprobe 截断明确报错。测试 102 项。
49
+ [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` | Join 2-20 clips; re-encoding normalizes size/fps/SAR with letterboxing and fills silent clips while keeping other audio | `inputs` required; with `reencode=true`, optionally set `width` + `height` and `fps` |
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 + 110 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` 数组必填;`reencode=true` 时可用 `width` + `height`、`fps` 指定输出 |
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 # 构建 + 110 个测试
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/args.d.ts CHANGED
@@ -28,6 +28,17 @@ export interface ConcatSpec {
28
28
  reencode: boolean;
29
29
  /** 所有输入都含音频流(probe 结果):只有全有才能用 a=1,否则 concat filter 绑定失败。 */
30
30
  hasAudio?: boolean;
31
+ /** 统一到目标画布;等比缩放并补边,不拉伸画面。 */
32
+ normalize?: {
33
+ width: number;
34
+ height: number;
35
+ fps: number;
36
+ };
37
+ /** 缺音轨片段用等长静音补齐,保留其余片段的原声。 */
38
+ audioInputs?: Array<{
39
+ present: boolean;
40
+ duration: number;
41
+ }>;
31
42
  }
32
43
  /** 拼接:同编码流拷贝走 concat demuxer(需 list 文件),否则 filter_complex 重编码。 */
33
44
  export declare function concatArgs(ffmpeg: string, spec: ConcatSpec): string[];
package/lib/args.js CHANGED
@@ -37,11 +37,35 @@ export function concatArgs(ffmpeg, spec) {
37
37
  const parts = [ffmpeg, flag];
38
38
  for (const input of spec.inputs)
39
39
  parts.push('-i', input);
40
- parts.push('-filter_complex', 'concat=n=' + spec.inputs.length + ':v=1:a=' + (spec.hasAudio === false ? '0' : '1'));
41
- // a=0 时 filter 没有音频 pad,但 ffmpeg 仍可能自动选中输入音轨;用 -an 明确只要视频
42
- if (spec.hasAudio === false)
40
+ const hasAudio = spec.audioInputs?.some(input => input.present) ?? spec.hasAudio !== false;
41
+ const filters = [];
42
+ const pads = [];
43
+ spec.inputs.forEach((_input, i) => {
44
+ const steps = [];
45
+ if (spec.normalize !== undefined) {
46
+ const { width, height, fps } = spec.normalize;
47
+ // First convert anamorphic pixels to square pixels, then fit the shared canvas.
48
+ steps.push('scale=trunc(iw*sar/2)*2:ih', 'setsar=1', 'scale=' + width + ':' + height + ':force_original_aspect_ratio=decrease:force_divisible_by=2', 'pad=' + width + ':' + height + ':(ow-iw)/2:(oh-ih)/2', 'fps=' + fps);
49
+ }
50
+ steps.push('setsar=1', 'format=yuv420p', 'setpts=PTS-STARTPTS');
51
+ filters.push('[' + i + ':v:0]' + steps.join(',') + '[v' + i + ']');
52
+ pads.push('[v' + i + ']');
53
+ if (hasAudio) {
54
+ const audio = spec.audioInputs?.[i];
55
+ const source = audio?.present === false
56
+ ? 'anullsrc=r=48000:cl=stereo,atrim=duration=' + fmtSeconds(audio.duration)
57
+ : '[' + i + ':a:0]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo';
58
+ filters.push(source + ',asetpts=PTS-STARTPTS[a' + i + ']');
59
+ pads.push('[a' + i + ']');
60
+ }
61
+ });
62
+ filters.push(pads.join('') + 'concat=n=' + spec.inputs.length + ':v=1:a=' + (hasAudio ? '1[vout][aout]' : '0[vout]'));
63
+ parts.push('-filter_complex', filters.join(';'), '-map', '[vout]');
64
+ if (hasAudio)
65
+ parts.push('-map', '[aout]', '-c:a', 'aac');
66
+ else
43
67
  parts.push('-an');
44
- parts.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-c:a', 'aac', spec.output);
68
+ parts.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', spec.output);
45
69
  return parts;
46
70
  }
47
71
  /** concat demuxer 的 list 文件内容(路径中的单引号按 ffmpeg 规则转义)。 */
@@ -116,12 +140,12 @@ export function frameAtArgs(ffmpeg, spec) {
116
140
  /** GIF 第一遍:调色板生成(palettegen)。 */
117
141
  export function gifPaletteArgs(ffmpeg, spec) {
118
142
  const filter = 'fps=' + spec.fps + ',scale=' + spec.width + ':-1:flags=lanczos,palettegen';
119
- return [ffmpeg, '-y', '-i', spec.input, '-ss', fmtSeconds(spec.start), '-t', fmtSeconds(spec.duration), '-vf', filter, spec.palettePath];
143
+ return [ffmpeg, '-y', '-ss', fmtSeconds(spec.start), '-t', fmtSeconds(spec.duration), '-i', spec.input, '-vf', filter, '-frames:v', '1', '-update', '1', spec.palettePath];
120
144
  }
121
145
  /** GIF 第二遍:paletteuse 合成。 */
122
146
  export function gifUseArgs(ffmpeg, spec) {
123
147
  const filter = 'fps=' + spec.fps + ',scale=' + spec.width + ':-1:flags=lanczos[x];[x][1:v]paletteuse';
124
- return [ffmpeg, overwriteFlag(spec.overwrite), '-i', spec.input, '-ss', fmtSeconds(spec.start), '-t', fmtSeconds(spec.duration), '-i', spec.palettePath, '-filter_complex', filter, spec.output];
148
+ return [ffmpeg, overwriteFlag(spec.overwrite), '-ss', fmtSeconds(spec.start), '-t', fmtSeconds(spec.duration), '-i', spec.input, '-i', spec.palettePath, '-filter_complex', filter, spec.output];
125
149
  }
126
150
  /**
127
151
  * atempo 单滤镜只接受约 [0.5, 100],超出范围用多个 atempo 级联
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
  },
@@ -238,11 +243,14 @@ export function buildFfmpegTools(config, runner) {
238
243
  };
239
244
  const concat = {
240
245
  name: 'ffmpeg_concat',
241
- description: '拼接多个视频片段。默认要求编码一致(流拷贝,秒级完成);reencode=true 时统一重编码拼接(慢;先 probe 各输入,任一输入无音轨就按纯视频拼接,避免音轨绑定失败)。inputs 为 2-20 个文件路径。',
246
+ description: '拼接 2-20 个视频片段。默认同编码流拷贝;reencode=true 将不同分辨率、帧率和像素比例统一到第一个片段的画布,等比补边;可指定 width/height/fps。混合有声与无声素材时只为无声段补静音,保留其他段的声音。',
242
247
  parameters: compileParameters({
243
248
  inputs: { type: 'array', items: { type: 'string' }, required: true, description: '输入文件路径数组(2-20 个,必填)。' },
244
249
  output: { type: 'string', description: '输出路径(可选,默认第一个输入同目录加 .concat 后缀)。' },
245
250
  reencode: { type: 'boolean', description: '是否统一重编码拼接(默认 false=流拷贝)。' },
251
+ width: { type: 'integer', description: '重编码目标宽度;与 height 一起指定,2-8192 的偶数。默认采用首段宽度。' },
252
+ height: { type: 'integer', description: '重编码目标高度;与 width 一起指定,2-8192 的偶数。默认采用首段高度。' },
253
+ fps: { type: 'number', description: '重编码目标帧率 1-240。默认采用首段帧率,无法识别时用 30。' },
246
254
  }),
247
255
  output: {
248
256
  schema: produceSchema,
@@ -258,10 +266,24 @@ export function buildFfmpegTools(config, runner) {
258
266
  throw new Error('inputs 至少需要 2 个文件(当前 ' + inputs.length + ' 个)。');
259
267
  if (inputs.length > 20)
260
268
  throw new Error('inputs 最多 20 个文件(当前 ' + inputs.length + ' 个)。');
261
- const absolute = inputs.map(assertInputFile);
269
+ const absolute = inputs.map(input => assertInputFile(input, executionCwd(exec)));
262
270
  const reencode = args.reencode === true;
271
+ const requestedWidth = optionalNumber(args, 'width');
272
+ const requestedHeight = optionalNumber(args, 'height');
273
+ const requestedFps = optionalNumber(args, 'fps');
274
+ if ([requestedWidth, requestedHeight, requestedFps].some(value => value !== undefined) && !reencode)
275
+ throw new Error('width/height/fps 需要 reencode=true。');
276
+ if ((requestedWidth === undefined) !== (requestedHeight === undefined))
277
+ throw new Error('width 与 height 需要一起指定。');
278
+ for (const [name, value] of [['width', requestedWidth], ['height', requestedHeight]]) {
279
+ if (value !== undefined && (!Number.isInteger(value) || value < 2 || value > 8192 || value % 2 !== 0))
280
+ throw new Error(name + ' 必须是 2-8192 的偶数。');
281
+ }
282
+ if (requestedFps !== undefined && (!Number.isFinite(requestedFps) || requestedFps < 1 || requestedFps > 240))
283
+ throw new Error('fps 必须在 1-240 之间。');
263
284
  const firstExt = extname(absolute[0]) || '.mp4';
264
- const output = resolveOutputPath(absolute[0], optionalString(args, 'output'), '.concat', firstExt, cfg.overwrite);
285
+ const output = resolveOutputPath(absolute[0], optionalString(args, 'output'), '.concat', firstExt, cfg.overwrite, executionCwd(exec));
286
+ let normalized;
265
287
  if (!reencode) {
266
288
  const listPath = join(tmpdir(), 'dsh-ffmpeg-concat-' + Date.now() + '-' + randomUUID().slice(0, 8) + '.txt');
267
289
  writeFileSync(listPath, concatListContent(absolute), 'utf8');
@@ -273,18 +295,27 @@ export function buildFfmpegTools(config, runner) {
273
295
  }
274
296
  }
275
297
  else {
276
- // concat filter 的 a=1 要求每个输入都含音频轨,否则报 Error binding filtergraph inputs/outputs;先 probe 确认
277
- let allHaveAudio = true;
298
+ const media = [];
278
299
  for (const inputPath of absolute) {
279
300
  const probeResult = await runChecked(runner, probeArgs(cfg.ffprobePath, inputPath), Math.min(timeout, 60000), 'ffprobe', exec?.signal);
280
- if (parseProbeJson(probeResult.stdout).audio.length === 0) {
281
- allHaveAudio = false;
282
- break;
283
- }
301
+ const info = parseProbeJson(probeResult.stdout);
302
+ if (info.video === null || info.video.width < 1 || info.video.height < 1)
303
+ throw new Error('拼接输入缺少有效视频画面:' + inputPath);
304
+ media.push(info);
284
305
  }
285
- await runChecked(runner, concatArgs(cfg.ffmpegPath, { inputs: absolute, output, overwrite: cfg.overwrite, reencode: true, hasAudio: allHaveAudio }), timeout, 'ffmpeg 拼接', exec?.signal);
306
+ const first = media[0].video;
307
+ normalized = { width: requestedWidth ?? Math.ceil(first.width / 2) * 2, height: requestedHeight ?? Math.ceil(first.height / 2) * 2,
308
+ fps: requestedFps ?? (first.fps !== null && first.fps >= 1 && first.fps <= 240 ? first.fps : 30) };
309
+ const anyAudio = media.some(info => info.audio.length > 0);
310
+ const audioInputs = media.map((info, index) => {
311
+ const duration = info.video.durationSeconds ?? info.durationSeconds ?? 0;
312
+ if (anyAudio && info.audio.length === 0 && duration <= 0)
313
+ throw new Error('无法确定无声片段的时长,不能安全补齐静音:' + absolute[index]);
314
+ return { present: info.audio.length > 0, duration };
315
+ });
316
+ await runChecked(runner, concatArgs(cfg.ffmpegPath, { inputs: absolute, output, overwrite: cfg.overwrite, reencode: true, normalize: normalized, audioInputs }), timeout, 'ffmpeg 拼接', exec?.signal);
286
317
  }
287
- return { output, count: absolute.length, reencode };
318
+ return { output, count: absolute.length, reencode, ...(normalized === undefined ? {} : { normalized }) };
288
319
  },
289
320
  timeoutMs: timeout,
290
321
  };
@@ -308,7 +339,7 @@ export function buildFfmpegTools(config, runner) {
308
339
  },
309
340
  async execute(rawArgs, exec) {
310
341
  const args = asRecord(rawArgs);
311
- const input = assertInputFile(requiredString(args, 'input', '输入文件'));
342
+ const input = assertInputFile(requiredString(args, 'input', '输入文件'), executionCwd(exec));
312
343
  const presetRaw = optionalString(args, 'preset') ?? 'bilibili-1080p';
313
344
  if (!ENCODE_PRESETS.includes(presetRaw)) {
314
345
  throw new Error('preset 必须是 ' + ENCODE_PRESETS.join(' / ') + ' 之一(当前:' + presetRaw + ')。');
@@ -335,7 +366,7 @@ export function buildFfmpegTools(config, runner) {
335
366
  throw new Error('scale 格式必须是 宽:高,如 1920:1080 或 -2:720。');
336
367
  scale = scaleRaw;
337
368
  }
338
- const output = resolveOutputPath(input, optionalString(args, 'output'), '.encoded', extname(input) || '.mp4', cfg.overwrite);
369
+ const output = resolveOutputPath(input, optionalString(args, 'output'), '.encoded', extname(input) || '.mp4', cfg.overwrite, executionCwd(exec));
339
370
  await runChecked(runner, encodeArgs(cfg.ffmpegPath, { input, output, preset, crf, fps, scale, overwrite: cfg.overwrite }), timeout, 'ffmpeg 转码', exec?.signal);
340
371
  return { output, preset, crf: crf ?? 'preset', fps: fps ?? null, scale: scale ?? null };
341
372
  },
@@ -358,9 +389,9 @@ export function buildFfmpegTools(config, runner) {
358
389
  },
359
390
  async execute(rawArgs, exec) {
360
391
  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);
392
+ const input = assertInputFile(requiredString(args, 'input', '输入视频'), executionCwd(exec));
393
+ const subtitlePath = assertInputFile(requiredString(args, 'subtitle', '字幕文件'), executionCwd(exec));
394
+ const output = resolveOutputPath(input, optionalString(args, 'output'), '.sub', extname(input) || '.mp4', cfg.overwrite, executionCwd(exec));
364
395
  await runChecked(runner, subtitleArgs(cfg.ffmpegPath, { input, subtitle: subtitlePath, output, overwrite: cfg.overwrite }), timeout, 'ffmpeg 字幕', exec?.signal);
365
396
  return { output, mode: 'burn' };
366
397
  },
@@ -387,7 +418,7 @@ export function buildFfmpegTools(config, runner) {
387
418
  },
388
419
  async execute(rawArgs, exec) {
389
420
  const args = asRecord(rawArgs);
390
- const input = assertInputFile(requiredString(args, 'input', '输入文件'));
421
+ const input = assertInputFile(requiredString(args, 'input', '输入文件'), executionCwd(exec));
391
422
  const what = requiredString(args, 'what', '提取内容');
392
423
  const allowed = ['audio', 'frames', 'frame', 'subtitle'];
393
424
  if (!allowed.includes(what))
@@ -398,16 +429,17 @@ export function buildFfmpegTools(config, runner) {
398
429
  const streamIndex = typeof args.streamIndex === 'number' && Number.isInteger(args.streamIndex) && args.streamIndex >= 0 ? args.streamIndex : 0;
399
430
  let output;
400
431
  if (what === 'audio') {
401
- output = resolveOutputPath(input, optionalString(args, 'output'), '.audio', '.m4a', cfg.overwrite);
432
+ output = resolveOutputPath(input, optionalString(args, 'output'), '.audio', '.m4a', cfg.overwrite, executionCwd(exec));
402
433
  }
403
434
  else if (what === 'frame') {
404
- output = resolveOutputPath(input, optionalString(args, 'output'), '.frame', '.png', cfg.overwrite);
435
+ output = resolveOutputPath(input, optionalString(args, 'output'), '.frame', '.png', cfg.overwrite, executionCwd(exec));
405
436
  }
406
437
  else if (what === 'subtitle') {
407
- output = resolveOutputPath(input, optionalString(args, 'output'), '.subtitle', '.srt', cfg.overwrite);
438
+ output = resolveOutputPath(input, optionalString(args, 'output'), '.subtitle', '.srt', cfg.overwrite, executionCwd(exec));
408
439
  }
409
440
  else {
410
- const explicit = optionalString(args, 'output');
441
+ const rawOutput = optionalString(args, 'output');
442
+ const explicit = rawOutput === undefined ? undefined : resolve(executionCwd(exec), rawOutput);
411
443
  if (explicit !== undefined) {
412
444
  if (explicit.includes('%')) {
413
445
  output = explicit;
@@ -463,7 +495,7 @@ export function buildFfmpegTools(config, runner) {
463
495
  },
464
496
  async execute(rawArgs, exec) {
465
497
  const args = asRecord(rawArgs);
466
- const input = assertInputFile(requiredString(args, 'input', '输入视频'));
498
+ const input = assertInputFile(requiredString(args, 'input', '输入视频'), executionCwd(exec));
467
499
  const start = optionalTime(args, 'start') ?? 0;
468
500
  const duration = optionalTime(args, 'duration') ?? 10;
469
501
  if (duration <= 0)
@@ -472,7 +504,7 @@ export function buildFfmpegTools(config, runner) {
472
504
  const fps = typeof fpsRaw === 'number' && Number.isInteger(fpsRaw) ? Math.min(30, Math.max(1, fpsRaw)) : 10;
473
505
  const widthRaw = args.width;
474
506
  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);
507
+ const output = resolveOutputPath(input, optionalString(args, 'output'), '.gif', '.gif', cfg.overwrite, executionCwd(exec));
476
508
  // 调色板属于内部临时产物,不能借用用户输出旁的可预测路径;否则 -y 与
477
509
  // finally 清理都可能覆盖/删除用户原有的 <output>.palette.png。
478
510
  const paletteDir = mkdtempSync(join(tmpdir(), 'dsh-ffmpeg-gif-'));
@@ -509,13 +541,13 @@ export function buildFfmpegTools(config, runner) {
509
541
  },
510
542
  async execute(rawArgs, exec) {
511
543
  const args = asRecord(rawArgs);
512
- const input = assertInputFile(requiredString(args, 'input', '输入文件'));
544
+ const input = assertInputFile(requiredString(args, 'input', '输入文件'), executionCwd(exec));
513
545
  const formatRaw = optionalString(args, 'format')?.toLowerCase() ?? 'png';
514
546
  if (formatRaw !== 'png' && formatRaw !== 'jpg' && formatRaw !== 'jpeg')
515
547
  throw new Error('format 只支持 png 或 jpg。');
516
548
  const ext = formatRaw === 'png' ? '.png' : '.jpg';
517
549
  const times = stringArray(args, 'times');
518
- const outDir = optionalString(args, 'outputDir') ?? join(dirname(input), sanitizeName(basename(input, extname(input))) + '-frames');
550
+ const outDir = resolve(executionCwd(exec), optionalString(args, 'outputDir') ?? join(dirname(input), sanitizeName(basename(input, extname(input))) + '-frames'));
519
551
  mkdirSync(outDir, { recursive: true });
520
552
  // 每次运行独占 run-* 子目录:历史 frame-* 不参与计数,也不会被本次 -y 覆盖
521
553
  const runDir = mkdtempSync(join(outDir, 'run-'));
@@ -568,7 +600,7 @@ export function buildFfmpegTools(config, runner) {
568
600
  },
569
601
  async execute(rawArgs, exec) {
570
602
  const args = asRecord(rawArgs);
571
- const input = assertInputFile(requiredString(args, 'input', '输入文件'));
603
+ const input = assertInputFile(requiredString(args, 'input', '输入文件'), executionCwd(exec));
572
604
  const speed = optionalNumber(args, 'speed');
573
605
  if (speed !== undefined && (speed < 0.1 || speed > 100))
574
606
  throw new Error('speed 必须在 0.1-100 之间(当前:' + speed + ')。');
@@ -590,7 +622,7 @@ export function buildFfmpegTools(config, runner) {
590
622
  // 先探测:确认有没有音轨,避免对无声文件构建音频滤镜报错
591
623
  const probeResult = await runChecked(runner, probeArgs(cfg.ffprobePath, input), Math.min(timeout, 60000), 'ffprobe', exec?.signal);
592
624
  const hasAudio = parseProbeJson(probeResult.stdout).audio.length > 0;
593
- const output = resolveOutputPath(input, optionalString(args, 'output'), '.adjust', extname(input) || '.mp4', cfg.overwrite);
625
+ const output = resolveOutputPath(input, optionalString(args, 'output'), '.adjust', extname(input) || '.mp4', cfg.overwrite, executionCwd(exec));
594
626
  await runChecked(runner, adjustArgs(cfg.ffmpegPath, { input, output, overwrite: cfg.overwrite, speed, volume, mute, rotate, hasAudio }), timeout, 'ffmpeg 调整', exec?.signal);
595
627
  const ops = [];
596
628
  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.5.0",
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",