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 +27 -0
- package/README.en.md +25 -68
- package/README.md +24 -72
- package/docs/CHANGELOG.en.md +13 -0
- package/docs/USAGE.en.md +79 -0
- package/docs/USAGE.md +83 -0
- package/docs/VALIDATION.md +13 -0
- package/lib/args.d.ts +11 -0
- package/lib/args.js +30 -6
- package/lib/paths.d.ts +2 -2
- package/lib/paths.js +4 -4
- package/lib/tools.d.ts +7 -0
- package/lib/tools.js +64 -32
- package/package.json +5 -3
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
|
-
   
|
|
4
|
-
|
|
5
1
|
# dsh-ffmpeg
|
|
6
2
|
|
|
7
|
-
[
|
|
3
|
+
[中文](README.md)
|
|
8
4
|
|
|
9
|
-
|
|
5
|
+

|
|
10
6
|
|
|
11
|
-
|
|
7
|
+
Use FFmpeg to process existing audio and video files through natural-language requests.
|
|
12
8
|
|
|
13
|
-
|
|
9
|
+
[](https://www.npmjs.com/package/dsh-ffmpeg) [](https://www.npmjs.com/package/dsh-ffmpeg)
|
|
14
10
|
|
|
15
|
-
|
|
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
|
-
##
|
|
13
|
+
## What it does
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
20
|
+
## Install
|
|
24
21
|
|
|
25
|
-
|
|
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
|
|
25
|
+
dsh plugin --profile desktop add dsh-ffmpeg
|
|
29
26
|
```
|
|
30
27
|
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
For the web version, replace `desktop` with `web`. Restart DSH after installation.
|
|
33
29
|
|
|
34
|
-
##
|
|
30
|
+
## Start using it
|
|
35
31
|
|
|
36
|
-
|
|
32
|
+
Provide an input file and ask: “Trim this video to 30 seconds and export a shareable MP4.”
|
|
37
33
|
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
3
|
+
[English](README.en.md)
|
|
6
4
|
|
|
7
|
-

|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
用自然语言调用 FFmpeg,处理已有音视频文件。
|
|
10
8
|
|
|
9
|
+
[](https://www.npmjs.com/package/dsh-ffmpeg) [](https://www.npmjs.com/package/dsh-ffmpeg)
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-ffmpeg/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-ffmpeg/pulls)。
|
|
13
12
|
|
|
14
|
-
##
|
|
13
|
+
## 功能
|
|
15
14
|
|
|
16
|
-
|
|
15
|
+
- 探测媒体信息,裁剪、拼接和压制视频。
|
|
16
|
+
- 拼接不同尺寸和帧率的素材时可统一画布并等比补边;无声片段补静音,保留其余原声。
|
|
17
|
+
- 添加字幕、生成 GIF、抽取画面。
|
|
18
|
+
- 通过宿主 subprocess 执行命令。
|
|
17
19
|
|
|
18
20
|
## 安装
|
|
19
21
|
|
|
20
|
-
|
|
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
|
|
25
|
+
dsh plugin --profile desktop add dsh-ffmpeg
|
|
30
26
|
```
|
|
31
27
|
|
|
32
|
-
|
|
28
|
+
网页版把命令中的 `desktop` 改为 `web`。安装后重启 DSH。
|
|
33
29
|
|
|
30
|
+
## 开始使用
|
|
34
31
|
|
|
35
|
-
|
|
32
|
+
准备输入文件后可说:“把这段视频裁成 30 秒,并导出适合分享的 MP4。”
|
|
36
33
|
|
|
37
|
-
|
|
34
|
+
## 依赖与配置
|
|
38
35
|
|
|
39
|
-
|
|
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
|
-
|
|
79
|
-
- **进程走 DSH 官方 subprocess 服务**:超时 AbortSignal 会真正触发树级终止(SIGTERM → 强杀,Windows taskkill /T),本插件零运行时依赖
|
|
80
|
-
- **防覆写**:默认不覆盖同名文件(自动加序号),输出与输入相同直接拒绝
|
|
81
|
-
- **超时钳制**:单次操作 10 秒 - 2 小时;探测额外限制 60 秒
|
|
82
|
-
- **参数校验**:时间格式、预设枚举、crf/fps/scale 范围全部前置校验,输入目录、抽帧无扩展名输出等边界也有中文错误/自动修复
|
|
40
|
+
## 文档
|
|
83
41
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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 项.
|
package/docs/USAGE.en.md
ADDED
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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',
|
|
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', '-
|
|
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), '-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"description": "
|
|
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",
|