dsh-ffmpeg 0.1.0 → 0.2.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/README.en.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  # dsh-ffmpeg
4
4
 
5
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
6
+
5
7
  DSH (DeepSeek Harness) video-processing plugin: seven tools covering probing, cutting, concatenation, transcoding, subtitles, extraction and GIF creation — all powered by ffmpeg/ffprobe.
6
8
 
7
9
  ## Installation
@@ -10,7 +12,7 @@ DSH (DeepSeek Harness) video-processing plugin: seven tools covering probing, cu
10
12
  dsh plugin --profile web add dsh-ffmpeg
11
13
  ```
12
14
 
13
- ffmpeg must be installed locally (`ffmpeg -version` should work); use `ffmpegPath` / `ffprobePath` when it is not on PATH.
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.
14
16
 
15
17
  ## Configuration
16
18
 
@@ -20,8 +22,8 @@ Override the plugin row in your profile's `cordis.patch.yml` (defaults apply whe
20
22
  - id: ffmpeg
21
23
  name: 'dsh-ffmpeg'
22
24
  config:
23
- # ffmpegPath: C:\tools\ffmpeg\bin\ffmpeg.exe # explicit path when not on PATH
24
- # ffprobePath: C:\tools\ffmpeg\bin\ffprobe.exe
25
+ # ffmpegPath: C:\tools\ffmpeg\bin\ffmpeg.exe # explicit path (or DSH_FFMPEG_PATH)
26
+ # ffprobePath: C:\tools\ffmpeg\bin\ffprobe.exe # or DSH_FFPROBE_PATH
25
27
  timeoutMs: 300000 # per-operation timeout (default 5 min, 10s - 2h)
26
28
  # overwrite: true # allow overwriting outputs (default auto-suffix _1/_2)
27
29
  ```
@@ -30,7 +32,7 @@ Override the plugin row in your profile's `cordis.patch.yml` (defaults apply whe
30
32
 
31
33
  | Tool | Purpose | Key parameters |
32
34
  | :-- | :-- | :-- |
33
- | `ffmpeg_probe` | Probe media info (format/duration/resolution/fps/bitrate/audio/subtitle streams) | `input` required |
35
+ | `ffmpeg_probe` | Probe media info (format/duration/resolution/fps/bitrate/audio/subtitle streams; multi-video files return a full videos list) | `input` required |
34
36
  | `ffmpeg_cut` | Cut a clip (stream copy by default, accurate re-encode optional) | `input` required; `start`/`end`/`duration` |
35
37
  | `ffmpeg_concat` | Concatenate 2-20 clips (stream copy for identical codecs / re-encode for mixed) | `inputs` array required |
36
38
  | `ffmpeg_encode` | Transcode with presets (bilibili 1080p/4K, vertical 1080p, web-720p) plus crf/fps/scale overrides | `input` required; `preset` optional |
@@ -51,16 +53,16 @@ ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
51
53
  ## Safety
52
54
 
53
55
  - **No shell**: every argument is passed as its own argv element — user input cannot inject commands
54
- - **Runs on the official DSH subprocess service**: tree-scoped termination (timeout escalates SIGTERM → kill; taskkill /T on Windows), zero runtime dependencies
56
+ - **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
55
57
  - **No accidental overwrites**: existing outputs get auto-suffixed; output == input is rejected
56
58
  - **Timeout clamps**: per-operation 10s - 2h; probes additionally capped at 60s
57
- - **Input validation**: time formats, preset enums, crf/fps/scale ranges are validated up front with actionable errors
59
+ - **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
58
60
 
59
61
  ## Development
60
62
 
61
63
  ```bash
62
64
  pnpm install
63
- pnpm test # build + 50 tests, including a real-ffmpeg end-to-end suite (auto-skipped without ffmpeg)
65
+ pnpm test # build + 57 tests, including a real-ffmpeg end-to-end suite (auto-skipped without ffmpeg)
64
66
  ```
65
67
 
66
68
  ## License
package/README.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  # dsh-ffmpeg
4
4
 
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)
8
+
9
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
10
+
11
+
5
12
  DSH(DeepSeek Harness)视频处理工具插件:七个工具覆盖探测、剪辑、拼接、转码、字幕、提取与 GIF 制作,全部由 ffmpeg/ffprobe 完成。
6
13
 
7
14
  ## 安装
@@ -10,7 +17,7 @@ DSH(DeepSeek Harness)视频处理工具插件:七个工具覆盖探测、
10
17
  dsh plugin --profile web add dsh-ffmpeg
11
18
  ```
12
19
 
13
- 需要本机已安装 ffmpeg(`ffmpeg -version` 能出结果即可);不在 PATH 上时用 `ffmpegPath`/`ffprobePath` 显式指定。
20
+ 需要本机已安装 ffmpeg(`ffmpeg -version` 能出结果即可);不在 PATH 上时可用 `ffmpegPath`/`ffprobePath` 显式指定,或设置环境变量 `DSH_FFMPEG_PATH` / `DSH_FFPROBE_PATH`。
14
21
 
15
22
  ## 配置
16
23
 
@@ -20,8 +27,8 @@ dsh plugin --profile web add dsh-ffmpeg
20
27
  - id: ffmpeg
21
28
  name: 'dsh-ffmpeg'
22
29
  config:
23
- # ffmpegPath: C:\tools\ffmpeg\bin\ffmpeg.exe # 非 PATH 时显式指定
24
- # ffprobePath: C:\tools\ffmpeg\bin\ffprobe.exe
30
+ # ffmpegPath: C:\tools\ffmpeg\bin\ffmpeg.exe # 非 PATH 时显式指定(也可用 DSH_FFMPEG_PATH)
31
+ # ffprobePath: C:\tools\ffmpeg\bin\ffprobe.exe # 也可用 DSH_FFPROBE_PATH
25
32
  timeoutMs: 300000 # 单次操作超时(默认 5 分钟,10 秒 - 2 小时)
26
33
  # overwrite: true # 允许覆盖同名输出(默认自动加 _1/_2 序号)
27
34
  ```
@@ -30,7 +37,7 @@ dsh plugin --profile web add dsh-ffmpeg
30
37
 
31
38
  | 工具 | 作用 | 关键参数 |
32
39
  | :-- | :-- | :-- |
33
- | `ffmpeg_probe` | 探测媒体信息(格式/时长/分辨率/帧率/码率/音轨/字幕轨) | `input` 必填 |
40
+ | `ffmpeg_probe` | 探测媒体信息(格式/时长/分辨率/帧率/码率/音轨/字幕轨;多视频流时返回完整 videos 列表) | `input` 必填 |
34
41
  | `ffmpeg_cut` | 剪辑片段(默认流拷贝秒级,可精确重编码) | `input` 必填;`start`/`end`/`duration` |
35
42
  | `ffmpeg_concat` | 拼接 2-20 个片段(同编码流拷贝 / 混合编码重编码) | `inputs` 数组必填 |
36
43
  | `ffmpeg_encode` | 转码(B 站 1080p/4K、竖屏 1080p、web-720p 预设 + crf/fps/scale 覆盖) | `input` 必填;`preset` 可选 |
@@ -51,18 +58,19 @@ ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
51
58
  ## 安全设计
52
59
 
53
60
  - **无 shell**:所有参数以独立 argv 数组传递,用户输入不可能注入命令
54
- - **进程走 DSH 官方 subprocess 服务**:树级终止(超时自动 SIGTERM → 强杀,Windows taskkill /T),本插件零运行时依赖
61
+ - **进程走 DSH 官方 subprocess 服务**:超时 AbortSignal 会真正触发树级终止(SIGTERM → 强杀,Windows taskkill /T),本插件零运行时依赖
55
62
  - **防覆写**:默认不覆盖同名文件(自动加序号),输出与输入相同直接拒绝
56
63
  - **超时钳制**:单次操作 10 秒 - 2 小时;探测额外限制 60 秒
57
- - **参数校验**:时间格式、预设枚举、crf/fps/scale 范围全部前置校验,错误带中文指引
64
+ - **参数校验**:时间格式、预设枚举、crf/fps/scale 范围全部前置校验,输入目录、抽帧无扩展名输出等边界也有中文错误/自动修复
58
65
 
59
66
  ## 开发
60
67
 
61
68
  ```bash
62
69
  pnpm install
63
- pnpm test # 构建 + 50 个测试(含真实 ffmpeg 端到端集成,缺 ffmpeg 自动跳过)
70
+ pnpm test # 构建 + 57 个测试(含真实 ffmpeg 端到端集成,缺 ffmpeg 自动跳过)
64
71
  ```
65
72
 
66
73
  ## License
67
74
 
68
75
  MIT
76
+
package/lib/config.d.ts CHANGED
@@ -25,7 +25,7 @@ export interface ResolvedFfmpegConfig {
25
25
  * @param config - 插件行配置(可能为 undefined/null)。
26
26
  * @throws 配置值非法时抛出中文错误。
27
27
  */
28
- export declare function resolveConfig(config: FfmpegConfig | undefined | null): ResolvedFfmpegConfig;
28
+ export declare function resolveConfig(config: FfmpegConfig | undefined | null, env?: NodeJS.ProcessEnv): ResolvedFfmpegConfig;
29
29
  /**
30
30
  * 解析时间参数:接受秒数(正数)或 HH:MM:SS[.mmm] 字符串。
31
31
  * 返回秒(浮点);非法返回 null。
package/lib/config.js CHANGED
@@ -11,10 +11,10 @@ const DEFAULT_GRACE_MS = 15000;
11
11
  * @param config - 插件行配置(可能为 undefined/null)。
12
12
  * @throws 配置值非法时抛出中文错误。
13
13
  */
14
- export function resolveConfig(config) {
14
+ export function resolveConfig(config, env = process.env) {
15
15
  const cfg = config ?? {};
16
- const ffmpegPath = typeof cfg.ffmpegPath === 'string' && cfg.ffmpegPath.trim() !== '' ? cfg.ffmpegPath.trim() : 'ffmpeg';
17
- const ffprobePath = typeof cfg.ffprobePath === 'string' && cfg.ffprobePath.trim() !== '' ? cfg.ffprobePath.trim() : 'ffprobe';
16
+ const ffmpegPath = typeof cfg.ffmpegPath === 'string' && cfg.ffmpegPath.trim() !== '' ? cfg.ffmpegPath.trim() : (env.DSH_FFMPEG_PATH?.trim() || 'ffmpeg');
17
+ const ffprobePath = typeof cfg.ffprobePath === 'string' && cfg.ffprobePath.trim() !== '' ? cfg.ffprobePath.trim() : (env.DSH_FFPROBE_PATH?.trim() || 'ffprobe');
18
18
  let timeoutMs = DEFAULT_TIMEOUT_MS;
19
19
  if (cfg.timeoutMs !== undefined) {
20
20
  if (typeof cfg.timeoutMs !== 'number' || !Number.isFinite(cfg.timeoutMs) || cfg.timeoutMs <= 0) {
package/lib/exec.js CHANGED
@@ -28,14 +28,14 @@ export function createSubprocessRunner(spawn, graceMs, defaultTimeoutMs) {
28
28
  graceMs,
29
29
  signal: controller.signal,
30
30
  });
31
+ const outcome = await handle.done;
32
+ const stdout = handle.collected.stdout?.readFrom(0).text ?? '';
33
+ const stderr = handle.collected.stderr?.readFrom(0).text ?? '';
34
+ return { exitCode: outcome.exitCode, signal: outcome.signal, stdout, stderr };
31
35
  }
32
36
  finally {
33
37
  clearTimeout(timer);
34
38
  }
35
- const outcome = await handle.done;
36
- const stdout = handle.collected.stdout?.readFrom(0).text ?? '';
37
- const stderr = handle.collected.stderr?.readFrom(0).text ?? '';
38
- return { exitCode: outcome.exitCode, signal: outcome.signal, stdout, stderr };
39
39
  },
40
40
  };
41
41
  }
package/lib/ffprobe.d.ts CHANGED
@@ -30,7 +30,10 @@ export interface MediaInfo {
30
30
  durationSeconds: number | null;
31
31
  sizeBytes: number | null;
32
32
  bitrate: number | null;
33
+ /** 第一个视频流(保持旧字段兼容)。 */
33
34
  video: VideoStreamInfo | null;
35
+ /** 全部视频流(多机位/多角度视频可完整枚举)。 */
36
+ videos: VideoStreamInfo[];
34
37
  audio: AudioStreamInfo[];
35
38
  subtitles: SubtitleStreamInfo[];
36
39
  }
package/lib/ffprobe.js CHANGED
@@ -52,20 +52,20 @@ export function parseProbeJson(text) {
52
52
  const root = (typeof raw === 'object' && raw !== null ? raw : {});
53
53
  const format = (typeof root.format === 'object' && root.format !== null ? root.format : {});
54
54
  const streams = Array.isArray(root.streams) ? root.streams : [];
55
- let video = null;
55
+ const videos = [];
56
56
  const audio = [];
57
57
  const subtitles = [];
58
58
  for (const stream of streams) {
59
59
  const codecType = str(stream.codec_type);
60
- if (codecType === 'video' && video === null) {
61
- video = {
60
+ if (codecType === 'video') {
61
+ videos.push({
62
62
  width: num(stream.width) ?? 0,
63
63
  height: num(stream.height) ?? 0,
64
64
  fps: parseFps(stream.avg_frame_rate) ?? parseFps(stream.r_frame_rate),
65
65
  codec: str(stream.codec_name),
66
66
  durationSeconds: num(stream.duration) ?? null,
67
67
  bitrate: num(stream.bit_rate) ?? null,
68
- };
68
+ });
69
69
  }
70
70
  else if (codecType === 'audio') {
71
71
  audio.push({
@@ -88,7 +88,8 @@ export function parseProbeJson(text) {
88
88
  durationSeconds: num(format.duration) ?? null,
89
89
  sizeBytes: num(format.size) ?? null,
90
90
  bitrate: num(format.bit_rate) ?? null,
91
- video,
91
+ video: videos[0] ?? null,
92
+ videos,
92
93
  audio,
93
94
  subtitles,
94
95
  };
package/lib/paths.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * @module dsh-ffmpeg/paths
5
5
  */
6
- import { existsSync } from 'node:fs';
6
+ import { existsSync, statSync } from 'node:fs';
7
7
  import { basename, dirname, extname, join, resolve } from 'node:path';
8
8
  /** 校验输入文件存在且是文件;返回绝对路径。 */
9
9
  export function assertInputFile(input) {
@@ -11,6 +11,9 @@ export function assertInputFile(input) {
11
11
  if (!existsSync(absolute)) {
12
12
  throw new Error('输入文件不存在:' + input);
13
13
  }
14
+ if (!statSync(absolute).isFile()) {
15
+ throw new Error('输入路径不是文件:' + input);
16
+ }
14
17
  return absolute;
15
18
  }
16
19
  /** 清洗文件名中的危险字符。 */
package/lib/tools.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * @module dsh-ffmpeg/tools
6
6
  */
7
7
  import { rmSync, writeFileSync } from 'node:fs';
8
+ import { randomUUID } from 'node:crypto';
8
9
  import { tmpdir } from 'node:os';
9
10
  import { basename, dirname, extname, join } from 'node:path';
10
11
  import { concatArgs, concatListContent, cutArgs, ENCODE_PRESETS, encodeArgs, extractArgs, fmtSeconds, gifPaletteArgs, gifUseArgs, probeArgs, subtitleArgs, } from './args.js';
@@ -102,6 +103,7 @@ const probeSchema = {
102
103
  sizeBytes: { type: 'number' },
103
104
  bitrate: { type: 'number' },
104
105
  video: videoStreamSchema,
106
+ videos: { type: 'array', items: videoStreamSchema },
105
107
  audio: { type: 'array', items: audioStreamSchema },
106
108
  subtitles: { type: 'array', items: subtitleStreamSchema },
107
109
  },
@@ -131,11 +133,13 @@ export function buildFfmpegTools(config, runner) {
131
133
  schema: probeSchema,
132
134
  render: buildTextRenderer((_args, value) => {
133
135
  const rec = asRecord(value);
134
- const video = asRecord(rec.video);
136
+ const videos = Array.isArray(rec.videos) ? rec.videos : [];
137
+ const video = videos.length > 0 ? asRecord(videos[0]) : asRecord(rec.video);
135
138
  const lines = ['媒体信息(' + rec.input + '):'];
136
139
  lines.push('- 容器:' + rec.formatName + ',时长:' + (rec.durationSeconds ?? '未知') + ' 秒,大小:' + (rec.sizeBytes ?? '未知') + ' 字节');
137
- if (rec.video !== null && rec.video !== undefined)
138
- lines.push('- 视频:' + video.codec + ' ' + video.width + 'x' + video.height + ',帧率:' + (video.fps ?? '未知'));
140
+ if (videos.length > 0) {
141
+ lines.push('- 视频流 ' + videos.length + ' 个;主视频:' + video.codec + ' ' + video.width + 'x' + video.height + ',帧率:' + (video.fps ?? '未知'));
142
+ }
139
143
  lines.push('- 音频流:' + (Array.isArray(rec.audio) ? rec.audio.length : 0) + ' 个,字幕流:' + (Array.isArray(rec.subtitles) ? rec.subtitles.length : 0) + ' 个');
140
144
  return lines;
141
145
  }),
@@ -217,7 +221,7 @@ export function buildFfmpegTools(config, runner) {
217
221
  const firstExt = extname(absolute[0]) || '.mp4';
218
222
  const output = resolveOutputPath(absolute[0], optionalString(args, 'output'), '.concat', firstExt, cfg.overwrite);
219
223
  if (!reencode) {
220
- const listPath = join(tmpdir(), 'dsh-ffmpeg-concat-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8) + '.txt');
224
+ const listPath = join(tmpdir(), 'dsh-ffmpeg-concat-' + Date.now() + '-' + randomUUID().slice(0, 8) + '.txt');
221
225
  writeFileSync(listPath, concatListContent(absolute), 'utf8');
222
226
  try {
223
227
  await runChecked(runner, concatArgs(cfg.ffmpegPath, { inputs: absolute, listFilePath: listPath, output, overwrite: cfg.overwrite, reencode: false }), timeout, 'ffmpeg 拼接');
@@ -354,7 +358,13 @@ export function buildFfmpegTools(config, runner) {
354
358
  else {
355
359
  const explicit = optionalString(args, 'output');
356
360
  if (explicit !== undefined) {
357
- output = explicit.includes('%') ? explicit : explicit.replace(/(\.[^.]+)$/, '-%03d$1');
361
+ if (explicit.includes('%')) {
362
+ output = explicit;
363
+ }
364
+ else {
365
+ const explicitExt = extname(explicit);
366
+ output = explicitExt === '' ? explicit + '-%03d.png' : explicit.slice(0, -explicitExt.length) + '-%03d' + explicitExt;
367
+ }
358
368
  }
359
369
  else {
360
370
  output = join(dirname(input), sanitizeName(basename(input, extname(input))) + '-%03d.png');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-ffmpeg",
3
- "version": "0.1.0",
4
- "description": "DSH 视频处理工具插件:ffmpeg_probe/cut/concat/encode/subtitle/extract/gif 七工具,进程走官方 subprocess 服务,零运行时依赖(ffmpeg 本体除外)。",
3
+ "version": "0.2.0",
4
+ "description": "DSH 视频处理工具插件:ffmpeg_probe/cut/concat/encode/subtitle/extract/gif 七工具,进程走官方 subprocess 服务,超时树级终止、环境变量路径回退与多视频流探测,零运行时依赖(ffmpeg 本体除外)。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -37,7 +37,8 @@
37
37
  "plugin",
38
38
  "ffmpeg",
39
39
  "video",
40
- "media"
40
+ "media",
41
+ "dsh-plugin"
41
42
  ],
42
43
  "license": "MIT",
43
44
  "engines": {
@@ -49,7 +50,7 @@
49
50
  },
50
51
  "repository": {
51
52
  "type": "git",
52
- "url": "https://github.com/STARDUSTLC666/dsh-ffmpeg"
53
+ "url": "git+https://github.com/STARDUSTLC666/dsh-ffmpeg.git"
53
54
  },
54
55
  "bugs": {
55
56
  "url": "https://github.com/STARDUSTLC666/dsh-ffmpeg/issues"