dsh-ffmpeg 0.1.1 → 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 stardustlc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md CHANGED
@@ -6,13 +6,26 @@
6
6
 
7
7
  DSH (DeepSeek Harness) video-processing plugin: seven tools covering probing, cutting, concatenation, transcoding, subtitles, extraction and GIF creation — all powered by ffmpeg/ffprobe.
8
8
 
9
+ ## Compatibility
10
+
11
+ Verified against `@deepseek-ai/dsh@0.1.1-rc.2` on 2026-08-26. Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
12
+
9
13
  ## Installation
10
14
 
11
15
  ```bash
12
16
  dsh plugin --profile web add dsh-ffmpeg
13
17
  ```
14
18
 
15
- ffmpeg must be installed locally (`ffmpeg -version` should work); use `ffmpegPath` / `ffprobePath` when it is not on PATH.
19
+ 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
+
21
+ ## Uninstall
22
+
23
+ ```bash
24
+ dsh plugin --profile web remove dsh-ffmpeg
25
+ ```
26
+
27
+ Then restart the web service. To clean up fully, also remove the plugin entry from your profile `cordis.patch.yml` if you overrode it.
28
+
16
29
 
17
30
  ## Configuration
18
31
 
@@ -22,8 +35,8 @@ Override the plugin row in your profile's `cordis.patch.yml` (defaults apply whe
22
35
  - id: ffmpeg
23
36
  name: 'dsh-ffmpeg'
24
37
  config:
25
- # ffmpegPath: C:\tools\ffmpeg\bin\ffmpeg.exe # explicit path when not on PATH
26
- # ffprobePath: C:\tools\ffmpeg\bin\ffprobe.exe
38
+ # ffmpegPath: C:\tools\ffmpeg\bin\ffmpeg.exe # explicit path (or DSH_FFMPEG_PATH)
39
+ # ffprobePath: C:\tools\ffmpeg\bin\ffprobe.exe # or DSH_FFPROBE_PATH
27
40
  timeoutMs: 300000 # per-operation timeout (default 5 min, 10s - 2h)
28
41
  # overwrite: true # allow overwriting outputs (default auto-suffix _1/_2)
29
42
  ```
@@ -32,7 +45,7 @@ Override the plugin row in your profile's `cordis.patch.yml` (defaults apply whe
32
45
 
33
46
  | Tool | Purpose | Key parameters |
34
47
  | :-- | :-- | :-- |
35
- | `ffmpeg_probe` | Probe media info (format/duration/resolution/fps/bitrate/audio/subtitle streams) | `input` required |
48
+ | `ffmpeg_probe` | Probe media info (format/duration/resolution/fps/bitrate/audio/subtitle streams; multi-video files return a full videos list) | `input` required |
36
49
  | `ffmpeg_cut` | Cut a clip (stream copy by default, accurate re-encode optional) | `input` required; `start`/`end`/`duration` |
37
50
  | `ffmpeg_concat` | Concatenate 2-20 clips (stream copy for identical codecs / re-encode for mixed) | `inputs` array required |
38
51
  | `ffmpeg_encode` | Transcode with presets (bilibili 1080p/4K, vertical 1080p, web-720p) plus crf/fps/scale overrides | `input` required; `preset` optional |
@@ -53,18 +66,18 @@ ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
53
66
  ## Safety
54
67
 
55
68
  - **No shell**: every argument is passed as its own argv element — user input cannot inject commands
56
- - **Runs on the official DSH subprocess service**: tree-scoped termination (timeout escalates SIGTERM → kill; taskkill /T on Windows), zero runtime dependencies
69
+ - **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
57
70
  - **No accidental overwrites**: existing outputs get auto-suffixed; output == input is rejected
58
71
  - **Timeout clamps**: per-operation 10s - 2h; probes additionally capped at 60s
59
- - **Input validation**: time formats, preset enums, crf/fps/scale ranges are validated up front with actionable errors
72
+ - **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
60
73
 
61
74
  ## Development
62
75
 
63
76
  ```bash
64
77
  pnpm install
65
- pnpm test # build + 50 tests, including a real-ffmpeg end-to-end suite (auto-skipped without ffmpeg)
78
+ pnpm test # build + 57 tests, including a real-ffmpeg end-to-end suite (auto-skipped without ffmpeg)
66
79
  ```
67
80
 
68
81
  ## License
69
82
 
70
- MIT
83
+ MIT
package/README.md CHANGED
@@ -11,13 +11,26 @@
11
11
 
12
12
  DSH(DeepSeek Harness)视频处理工具插件:七个工具覆盖探测、剪辑、拼接、转码、字幕、提取与 GIF 制作,全部由 ffmpeg/ffprobe 完成。
13
13
 
14
+ ## 兼容性
15
+
16
+ 在 `@deepseek-ai/dsh@0.1.1-rc.2` 上验证(2026-08-26)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
17
+
14
18
  ## 安装
15
19
 
16
20
  ```bash
17
21
  dsh plugin --profile web add dsh-ffmpeg
18
22
  ```
19
23
 
20
- 需要本机已安装 ffmpeg(`ffmpeg -version` 能出结果即可);不在 PATH 上时用 `ffmpegPath`/`ffprobePath` 显式指定。
24
+ 需要本机已安装 ffmpeg(`ffmpeg -version` 能出结果即可);不在 PATH 上时可用 `ffmpegPath`/`ffprobePath` 显式指定,或设置环境变量 `DSH_FFMPEG_PATH` / `DSH_FFPROBE_PATH`。
25
+
26
+ ## 卸载
27
+
28
+ ```bash
29
+ dsh plugin --profile web remove dsh-ffmpeg
30
+ ```
31
+
32
+ 卸载后重启 Web 服务。如需彻底清理,可再手动删除自己 profile `cordis.patch.yml` 中覆盖的插件行。
33
+
21
34
 
22
35
  ## 配置
23
36
 
@@ -27,8 +40,8 @@ dsh plugin --profile web add dsh-ffmpeg
27
40
  - id: ffmpeg
28
41
  name: 'dsh-ffmpeg'
29
42
  config:
30
- # ffmpegPath: C:\tools\ffmpeg\bin\ffmpeg.exe # 非 PATH 时显式指定
31
- # ffprobePath: C:\tools\ffmpeg\bin\ffprobe.exe
43
+ # ffmpegPath: C:\tools\ffmpeg\bin\ffmpeg.exe # 非 PATH 时显式指定(也可用 DSH_FFMPEG_PATH)
44
+ # ffprobePath: C:\tools\ffmpeg\bin\ffprobe.exe # 也可用 DSH_FFPROBE_PATH
32
45
  timeoutMs: 300000 # 单次操作超时(默认 5 分钟,10 秒 - 2 小时)
33
46
  # overwrite: true # 允许覆盖同名输出(默认自动加 _1/_2 序号)
34
47
  ```
@@ -37,7 +50,7 @@ dsh plugin --profile web add dsh-ffmpeg
37
50
 
38
51
  | 工具 | 作用 | 关键参数 |
39
52
  | :-- | :-- | :-- |
40
- | `ffmpeg_probe` | 探测媒体信息(格式/时长/分辨率/帧率/码率/音轨/字幕轨) | `input` 必填 |
53
+ | `ffmpeg_probe` | 探测媒体信息(格式/时长/分辨率/帧率/码率/音轨/字幕轨;多视频流时返回完整 videos 列表) | `input` 必填 |
41
54
  | `ffmpeg_cut` | 剪辑片段(默认流拷贝秒级,可精确重编码) | `input` 必填;`start`/`end`/`duration` |
42
55
  | `ffmpeg_concat` | 拼接 2-20 个片段(同编码流拷贝 / 混合编码重编码) | `inputs` 数组必填 |
43
56
  | `ffmpeg_encode` | 转码(B 站 1080p/4K、竖屏 1080p、web-720p 预设 + crf/fps/scale 覆盖) | `input` 必填;`preset` 可选 |
@@ -58,19 +71,18 @@ ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
58
71
  ## 安全设计
59
72
 
60
73
  - **无 shell**:所有参数以独立 argv 数组传递,用户输入不可能注入命令
61
- - **进程走 DSH 官方 subprocess 服务**:树级终止(超时自动 SIGTERM → 强杀,Windows taskkill /T),本插件零运行时依赖
74
+ - **进程走 DSH 官方 subprocess 服务**:超时 AbortSignal 会真正触发树级终止(SIGTERM → 强杀,Windows taskkill /T),本插件零运行时依赖
62
75
  - **防覆写**:默认不覆盖同名文件(自动加序号),输出与输入相同直接拒绝
63
76
  - **超时钳制**:单次操作 10 秒 - 2 小时;探测额外限制 60 秒
64
- - **参数校验**:时间格式、预设枚举、crf/fps/scale 范围全部前置校验,错误带中文指引
77
+ - **参数校验**:时间格式、预设枚举、crf/fps/scale 范围全部前置校验,输入目录、抽帧无扩展名输出等边界也有中文错误/自动修复
65
78
 
66
79
  ## 开发
67
80
 
68
81
  ```bash
69
82
  pnpm install
70
- pnpm test # 构建 + 50 个测试(含真实 ffmpeg 端到端集成,缺 ffmpeg 自动跳过)
83
+ pnpm test # 构建 + 57 个测试(含真实 ffmpeg 端到端集成,缺 ffmpeg 自动跳过)
71
84
  ```
72
85
 
73
86
  ## License
74
87
 
75
- MIT
76
-
88
+ MIT
package/lib/args.d.ts CHANGED
@@ -62,9 +62,17 @@ export interface ExtractSpec {
62
62
  duration?: number;
63
63
  fps?: number;
64
64
  streamIndex: number;
65
+ maxFrames?: number;
65
66
  }
66
67
  /** 提取:音频(拷贝)/ 抽帧序列 / 单帧 / 字幕流。 */
67
68
  export declare function extractArgs(ffmpeg: string, spec: ExtractSpec): string[];
69
+ /** 定点抽帧:在指定时间点取一帧。 */
70
+ export declare function frameAtArgs(ffmpeg: string, spec: {
71
+ input: string;
72
+ time: number;
73
+ output: string;
74
+ overwrite: boolean;
75
+ }): string[];
68
76
  export interface GifSpec {
69
77
  input: string;
70
78
  output: string;
package/lib/args.js CHANGED
@@ -91,9 +91,16 @@ export function extractArgs(ffmpeg, spec) {
91
91
  parts.push('-ss', fmtSeconds(spec.start));
92
92
  if (spec.duration !== undefined)
93
93
  parts.push('-t', fmtSeconds(spec.duration));
94
+ if (spec.maxFrames !== undefined)
95
+ parts.push('-frames:v', String(spec.maxFrames));
94
96
  parts.push('-vf', 'fps=' + (spec.fps ?? 1), spec.output);
95
97
  return parts;
96
98
  }
99
+ /** 定点抽帧:在指定时间点取一帧。 */
100
+ export function frameAtArgs(ffmpeg, spec) {
101
+ const flag = overwriteFlag(spec.overwrite);
102
+ return [ffmpeg, flag, '-ss', fmtSeconds(spec.time), '-i', spec.input, '-frames:v', '1', spec.output];
103
+ }
97
104
  /** GIF 第一遍:调色板生成(palettegen)。 */
98
105
  export function gifPaletteArgs(ffmpeg, spec) {
99
106
  const filter = 'fps=' + spec.fps + ',scale=' + spec.width + ':-1:flags=lanczos,palettegen';
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/index.d.ts CHANGED
@@ -12,6 +12,7 @@ import { type FfmpegConfig } from './config.js';
12
12
  import { type SubprocessSpawnLike } from './exec.js';
13
13
  import { type FfmpegToolDefinition } from './tools.js';
14
14
  /** cordis 服务注入:apply 里要用 ctx.subprocess 与 ctx.tools,必须显式声明,否则宿主会抛 cannot get property without inject。 */
15
+ export declare const name = "ffmpeg";
15
16
  export declare const inject: string[];
16
17
  /** 插件所需的最小 ctx 面(社区插件不依赖宿主内部类型)。 */
17
18
  export interface FfmpegPluginContext {
package/lib/index.js CHANGED
@@ -12,6 +12,7 @@ import { resolveConfig } from './config.js';
12
12
  import { createSubprocessRunner } from './exec.js';
13
13
  import { buildFfmpegTools } from './tools.js';
14
14
  /** cordis 服务注入:apply 里要用 ctx.subprocess 与 ctx.tools,必须显式声明,否则宿主会抛 cannot get property without inject。 */
15
+ export const name = 'ffmpeg';
15
16
  export const inject = ['subprocess', 'tools'];
16
17
  /**
17
18
  * 插件入口:解析配置、封装 subprocess 执行器并注册七个视频工具。
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.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
- * 七个面向模型的视频工具:probe / cut / concat / encode / subtitle / extract / gif。
2
+ * 八个面向模型的视频工具:probe / cut / concat / encode / subtitle / extract / gif / frames
3
3
  * 直接调用 ctx.tools.register 注册【编译好的 JSON Schema】参数与 canonical 输出。
4
4
  *
5
5
  * @module dsh-ffmpeg/tools
6
6
  */
7
7
  import { type ResolvedFfmpegConfig } from './config.js';
8
8
  import { type ProcessRunner } from './exec.js';
9
+ import { type MediaInfo } from './ffprobe.js';
9
10
  /** 模型可见的内容块。 */
10
11
  export interface ContentBlock {
11
12
  type: 'text';
@@ -27,6 +28,8 @@ export interface FfmpegToolDefinition {
27
28
  execute(args: unknown, exec: unknown): Promise<unknown>;
28
29
  timeoutMs?: number;
29
30
  }
31
+ /** 生成一行人类可读的媒体摘要:容器、时长、主视频、帧率、码率、体积。 */
32
+ export declare function buildProbeSummary(media: MediaInfo): string;
30
33
  /**
31
34
  * 构建七个工具定义。
32
35
  * @param config - 已解析配置。
package/lib/tools.js CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
- * 七个面向模型的视频工具:probe / cut / concat / encode / subtitle / extract / gif。
2
+ * 八个面向模型的视频工具:probe / cut / concat / encode / subtitle / extract / gif / frames
3
3
  * 直接调用 ctx.tools.register 注册【编译好的 JSON Schema】参数与 canonical 输出。
4
4
  *
5
5
  * @module dsh-ffmpeg/tools
6
6
  */
7
- import { rmSync, writeFileSync } from 'node:fs';
7
+ import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { basename, dirname, extname, join } from 'node:path';
11
- import { concatArgs, concatListContent, cutArgs, ENCODE_PRESETS, encodeArgs, extractArgs, fmtSeconds, gifPaletteArgs, gifUseArgs, probeArgs, subtitleArgs, } from './args.js';
11
+ import { 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';
@@ -98,11 +98,13 @@ const probeSchema = {
98
98
  properties: {
99
99
  ok: { type: 'boolean' },
100
100
  input: { type: 'string' },
101
+ summary: { type: 'string' },
101
102
  formatName: { type: 'string' },
102
103
  durationSeconds: { type: 'number' },
103
104
  sizeBytes: { type: 'number' },
104
105
  bitrate: { type: 'number' },
105
106
  video: videoStreamSchema,
107
+ videos: { type: 'array', items: videoStreamSchema },
106
108
  audio: { type: 'array', items: audioStreamSchema },
107
109
  subtitles: { type: 'array', items: subtitleStreamSchema },
108
110
  },
@@ -113,6 +115,36 @@ const produceSchema = {
113
115
  properties: { output: { type: 'string' } },
114
116
  additionalProperties: true,
115
117
  };
118
+ // ---------- probe 摘要 ----------
119
+ /** 码率人类可读。 */
120
+ function formatBitrate(bps) {
121
+ if (bps === null)
122
+ return '码率未知';
123
+ if (bps >= 1000000)
124
+ return (bps / 1000000).toFixed(2) + ' Mbps';
125
+ return Math.round(bps / 1000) + ' kbps';
126
+ }
127
+ /** 生成一行人类可读的媒体摘要:容器、时长、主视频、帧率、码率、体积。 */
128
+ export function buildProbeSummary(media) {
129
+ const parts = [];
130
+ if (media.formatName !== '')
131
+ parts.push(media.formatName);
132
+ if (media.durationSeconds !== null)
133
+ parts.push(fmtSeconds(media.durationSeconds));
134
+ if (media.video !== null) {
135
+ const v = media.video;
136
+ parts.push((v.codec !== '' ? v.codec + ' ' : '') + v.width + 'x' + v.height);
137
+ if (v.fps !== null)
138
+ parts.push(Math.round(v.fps * 100) / 100 + ' fps');
139
+ }
140
+ parts.push(formatBitrate(media.bitrate));
141
+ if (media.sizeBytes !== null) {
142
+ const mb = media.sizeBytes / 1024 / 1024;
143
+ parts.push(mb >= 1 ? mb.toFixed(1) + ' MB' : Math.round(media.sizeBytes / 1024) + ' KB');
144
+ }
145
+ parts.push('音频流 ' + media.audio.length + ' / 字幕流 ' + media.subtitles.length);
146
+ return parts.join(',');
147
+ }
116
148
  // ---------- 工具构建 ----------
117
149
  /**
118
150
  * 构建七个工具定义。
@@ -132,11 +164,15 @@ export function buildFfmpegTools(config, runner) {
132
164
  schema: probeSchema,
133
165
  render: buildTextRenderer((_args, value) => {
134
166
  const rec = asRecord(value);
135
- const video = asRecord(rec.video);
167
+ const videos = Array.isArray(rec.videos) ? rec.videos : [];
168
+ const video = videos.length > 0 ? asRecord(videos[0]) : asRecord(rec.video);
136
169
  const lines = ['媒体信息(' + rec.input + '):'];
170
+ if (typeof rec.summary === 'string' && rec.summary !== '')
171
+ lines.push('- 摘要:' + rec.summary);
137
172
  lines.push('- 容器:' + rec.formatName + ',时长:' + (rec.durationSeconds ?? '未知') + ' 秒,大小:' + (rec.sizeBytes ?? '未知') + ' 字节');
138
- if (rec.video !== null && rec.video !== undefined)
139
- lines.push('- 视频:' + video.codec + ' ' + video.width + 'x' + video.height + ',帧率:' + (video.fps ?? '未知'));
173
+ if (videos.length > 0) {
174
+ lines.push('- 视频流 ' + videos.length + ' 个;主视频:' + video.codec + ' ' + video.width + 'x' + video.height + ',帧率:' + (video.fps ?? '未知'));
175
+ }
140
176
  lines.push('- 音频流:' + (Array.isArray(rec.audio) ? rec.audio.length : 0) + ' 个,字幕流:' + (Array.isArray(rec.subtitles) ? rec.subtitles.length : 0) + ' 个');
141
177
  return lines;
142
178
  }),
@@ -146,7 +182,7 @@ export function buildFfmpegTools(config, runner) {
146
182
  const input = assertInputFile(requiredString(args, 'input', '输入文件'));
147
183
  const result = await runChecked(runner, probeArgs(cfg.ffprobePath, input), Math.min(timeout, 60000), 'ffprobe');
148
184
  const media = parseProbeJson(result.stdout);
149
- return { ok: true, input, ...media };
185
+ return { ok: true, input, summary: buildProbeSummary(media), ...media };
150
186
  },
151
187
  timeoutMs: Math.min(timeout, 60000),
152
188
  };
@@ -355,7 +391,13 @@ export function buildFfmpegTools(config, runner) {
355
391
  else {
356
392
  const explicit = optionalString(args, 'output');
357
393
  if (explicit !== undefined) {
358
- output = explicit.includes('%') ? explicit : explicit.replace(/(\.[^.]+)$/, '-%03d$1');
394
+ if (explicit.includes('%')) {
395
+ output = explicit;
396
+ }
397
+ else {
398
+ const explicitExt = extname(explicit);
399
+ output = explicitExt === '' ? explicit + '-%03d.png' : explicit.slice(0, -explicitExt.length) + '-%03d' + explicitExt;
400
+ }
359
401
  }
360
402
  else {
361
403
  output = join(dirname(input), sanitizeName(basename(input, extname(input))) + '-%03d.png');
@@ -409,5 +451,102 @@ export function buildFfmpegTools(config, runner) {
409
451
  },
410
452
  timeoutMs: timeout,
411
453
  };
412
- return [probe, cut, concat, encode, subtitle, extract, gif];
454
+ const frames = {
455
+ name: 'ffmpeg_frames',
456
+ description: '从视频批量抽帧为图片(PNG/JPG)。两种模式:every(固定秒间隔抽帧,如 every=2 表示每 2 秒一帧,默认 1)或 times(指定时间点列表,如 ["00:00:05","00:01:30"],最多 20 个)。maxFrames 限制 every 模式的帧数上限(1-500,默认 100)。返回输出目录、文件清单与数量,便于后续视觉模型读图。',
457
+ parameters: compileParameters({
458
+ input: { type: 'string', required: true, description: '输入视频(必填)。' },
459
+ every: { type: 'number', description: '抽帧间隔秒数(与 times 二选一,默认 1)。' },
460
+ times: { type: 'array', items: { type: 'string' }, description: '时间点列表(与 every 二选一,最多 20 个,秒数或 HH:MM:SS.mmm)。' },
461
+ maxFrames: { type: 'integer', description: 'every 模式帧数上限 1-500(默认 100)。' },
462
+ outputDir: { type: 'string', description: '输出目录(可选,默认输入同目录 <文件名>-frames)。' },
463
+ format: { type: 'string', description: '图片格式:png(默认)或 jpg。' },
464
+ }),
465
+ output: {
466
+ schema: baseSchema,
467
+ render: buildTextRenderer((_args, value) => {
468
+ const rec = asRecord(value);
469
+ return ['抽帧完成:共 ' + rec.count + ' 张,输出目录 ' + rec.outputDir + (rec.mode === 'times' ? '(指定时间点)' : '(每 ' + rec.every + ' 秒一帧)')];
470
+ }),
471
+ },
472
+ async execute(rawArgs) {
473
+ const args = asRecord(rawArgs);
474
+ const input = assertInputFile(requiredString(args, 'input', '输入文件'));
475
+ const formatRaw = optionalString(args, 'format')?.toLowerCase() ?? 'png';
476
+ if (formatRaw !== 'png' && formatRaw !== 'jpg' && formatRaw !== 'jpeg')
477
+ throw new Error('format 只支持 png 或 jpg。');
478
+ const ext = formatRaw === 'png' ? '.png' : '.jpg';
479
+ const times = stringArray(args, 'times');
480
+ const outDir = optionalString(args, 'outputDir') ?? join(dirname(input), sanitizeName(basename(input, extname(input))) + '-frames');
481
+ mkdirSync(outDir, { recursive: true });
482
+ if (times.length > 0) {
483
+ if (times.length > 20)
484
+ throw new Error('times 最多 20 个时间点(当前 ' + times.length + ' 个)。');
485
+ let i = 0;
486
+ for (const raw of times) {
487
+ const at = parseTimeArg(raw);
488
+ if (at === null)
489
+ throw new Error('times 里第 ' + (i + 1) + ' 个时间点非法:' + raw + '(请用秒数或 HH:MM:SS.mmm)。');
490
+ const target = join(outDir, 'frame-' + String(i + 1).padStart(3, '0') + ext);
491
+ await runChecked(runner, frameAtArgs(cfg.ffmpegPath, { input, time: at, output: target, overwrite: cfg.overwrite }), timeout, 'ffmpeg 定点抽帧');
492
+ i++;
493
+ }
494
+ }
495
+ else {
496
+ const every = optionalNumber(args, 'every') ?? 1;
497
+ if (every <= 0)
498
+ throw new Error('every 必须大于 0。');
499
+ const maxFramesRaw = args.maxFrames;
500
+ const maxFrames = typeof maxFramesRaw === 'number' && Number.isInteger(maxFramesRaw) ? Math.min(500, Math.max(1, maxFramesRaw)) : 100;
501
+ const pattern = join(outDir, 'frame-%03d' + ext);
502
+ await runChecked(runner, extractArgs(cfg.ffmpegPath, { input, what: 'frames', output: pattern, overwrite: cfg.overwrite, fps: 1 / every, streamIndex: 0, maxFrames }), timeout, 'ffmpeg 抽帧');
503
+ }
504
+ const files = readdirSync(outDir).filter((f) => f.startsWith('frame-') && f.endsWith(ext)).sort();
505
+ const every = optionalNumber(args, 'every') ?? 1;
506
+ return { outputDir: outDir, mode: times.length > 0 ? 'times' : 'every', every: times.length > 0 ? null : every, count: files.length, files: files.slice(0, 200) };
507
+ },
508
+ timeoutMs: timeout,
509
+ };
510
+ const health = {
511
+ name: 'ffmpeg_health',
512
+ description: 'dsh-ffmpeg 自检:验证 ffmpeg / ffprobe 可执行文件是否可用(执行 -version)。遇到问题时先运行本工具定位。',
513
+ parameters: compileParameters({}),
514
+ output: {
515
+ schema: baseSchema,
516
+ render: buildTextRenderer((_args, value) => {
517
+ const rec = asRecord(value);
518
+ const checks = Array.isArray(rec.checks) ? rec.checks : [];
519
+ const lines = ['dsh-ffmpeg 自检' + (rec.ok === true ? ':正常。' : ':发现问题。')];
520
+ for (const item of checks) {
521
+ const c = asRecord(item);
522
+ lines.push('- ' + c.name + ':' + (c.ok === true ? '✅ ' + String(c.version ?? '') : '❌ ' + String(c.detail ?? '')));
523
+ }
524
+ return lines;
525
+ }),
526
+ },
527
+ async execute() {
528
+ const checks = [];
529
+ let ok = true;
530
+ for (const [label, bin] of [['ffmpeg', cfg.ffmpegPath], ['ffprobe', cfg.ffprobePath]]) {
531
+ try {
532
+ const result = await runner.run([bin, '-version'], { timeoutMs: 15000 });
533
+ const firstLine = result.stdout.split(/\r?\n/)[0]?.trim() ?? '';
534
+ if (result.exitCode === 0) {
535
+ checks.push({ name: label, ok: true, path: bin, version: firstLine });
536
+ }
537
+ else {
538
+ ok = false;
539
+ checks.push({ name: label, ok: false, path: bin, detail: '退出码 ' + String(result.exitCode) + ':' + firstLine });
540
+ }
541
+ }
542
+ catch (error) {
543
+ ok = false;
544
+ checks.push({ name: label, ok: false, path: bin, detail: error instanceof Error ? error.message : String(error) });
545
+ }
546
+ }
547
+ return { ok, plugin: 'dsh-ffmpeg', checks };
548
+ },
549
+ timeoutMs: 30000,
550
+ };
551
+ return [probe, cut, concat, encode, subtitle, extract, gif, frames, health];
413
552
  }
package/package.json CHANGED
@@ -1,61 +1,60 @@
1
- {
2
- "name": "dsh-ffmpeg",
3
- "version": "0.1.1",
4
- "description": "DSH 视频处理工具插件:ffmpeg_probe/cut/concat/encode/subtitle/extract/gif 七工具,进程走官方 subprocess 服务,零运行时依赖(ffmpeg 本体除外)。",
5
- "type": "module",
6
- "main": "lib/index.js",
7
- "types": "lib/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./lib/index.d.ts",
11
- "default": "./lib/index.js"
12
- },
13
- "./cordis.patch.yml": "./cordis.patch.yml",
14
- "./package.json": "./package.json"
15
- },
16
- "files": [
17
- "lib",
18
- "cordis.patch.yml",
19
- "README.md",
20
- "README.en.md"
21
- ],
22
- "scripts": {
23
- "build": "tsc -p tsconfig.json",
24
- "prepare": "tsc -p tsconfig.json",
25
- "typecheck": "tsc -p tsconfig.json --noEmit",
26
- "test": "pnpm run build && node --test \"test/*.test.mjs\"",
27
- "prepublishOnly": "pnpm run build"
28
- },
29
- "dsh": {
30
- "bundle": {
31
- "patch": "./cordis.patch.yml"
32
- }
33
- },
34
- "keywords": [
35
- "dsh",
36
- "deepseek-harness",
37
- "plugin",
38
- "ffmpeg",
39
- "video",
40
- "media",
41
- "dsh-plugin"
42
- ],
43
- "license": "MIT",
44
- "engines": {
45
- "node": ">=20"
46
- },
47
- "devDependencies": {
48
- "@types/node": "^24.0.0",
49
- "typescript": "^5.6.0"
50
- },
51
- "repository": {
52
- "type": "git",
53
- "url": "git+https://github.com/STARDUSTLC666/dsh-ffmpeg.git"
54
- },
55
- "bugs": {
56
- "url": "https://github.com/STARDUSTLC666/dsh-ffmpeg/issues"
57
- },
58
- "homepage": "https://github.com/STARDUSTLC666/dsh-ffmpeg#readme",
59
- "author": "stardustlc",
60
- "packageManager": "pnpm@11.7.0"
61
- }
1
+ {
2
+ "name": "dsh-ffmpeg",
3
+ "version": "0.3.0",
4
+ "description": "DSH 视频处理工具插件:ffmpeg_probe/cut/concat/encode/subtitle/extract/gif 七工具,进程走官方 subprocess 服务,超时树级终止、环境变量路径回退与多视频流探测,零运行时依赖(ffmpeg 本体除外)。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./cordis.patch.yml": "./cordis.patch.yml",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib",
18
+ "cordis.patch.yml",
19
+ "README.md",
20
+ "README.en.md"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json",
24
+ "typecheck": "tsc -p tsconfig.json --noEmit",
25
+ "test": "pnpm run build && node --test \"test/*.test.mjs\"",
26
+ "prepublishOnly": "pnpm run build"
27
+ },
28
+ "dsh": {
29
+ "bundle": {
30
+ "patch": "./cordis.patch.yml"
31
+ }
32
+ },
33
+ "keywords": [
34
+ "dsh",
35
+ "deepseek-harness",
36
+ "plugin",
37
+ "ffmpeg",
38
+ "video",
39
+ "media",
40
+ "dsh-plugin"
41
+ ],
42
+ "license": "MIT",
43
+ "engines": {
44
+ "node": ">=22"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^24.0.0",
48
+ "typescript": "^5.6.0"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/STARDUSTLC666/dsh-ffmpeg.git"
53
+ },
54
+ "bugs": {
55
+ "url": "https://github.com/STARDUSTLC666/dsh-ffmpeg/issues"
56
+ },
57
+ "homepage": "https://github.com/STARDUSTLC666/dsh-ffmpeg#readme",
58
+ "author": "stardustlc",
59
+ "packageManager": "pnpm@11.7.0"
60
+ }