dsh-ffmpeg 0.4.0 → 0.4.1

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
@@ -10,7 +10,7 @@ DSH (DeepSeek Harness) video-processing plugin: seven tools covering probing, cu
10
10
 
11
11
  ## Compatibility
12
12
 
13
- Verified against `@deepseek-ai/dsh@0.1.2-alpha.2` on 2026-08-31. Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
13
+ Verified against the official `@deepseek-ai/dsh@0.1.3-alpha.2` on 2026-09-08, including an 18-component co-load and the full tool registration/invocation contract. Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
14
14
 
15
15
  ## Installation
16
16
 
@@ -77,7 +77,7 @@ ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
77
77
 
78
78
  ```bash
79
79
  pnpm install
80
- pnpm test # build + 57 tests, including a real-ffmpeg end-to-end suite (auto-skipped without ffmpeg)
80
+ pnpm test # build + 83 tests, including a real-ffmpeg end-to-end suite (auto-skipped without ffmpeg)
81
81
  ```
82
82
 
83
83
  ## License
package/README.md CHANGED
@@ -13,7 +13,7 @@ DSH(DeepSeek Harness)视频处理工具插件:十个工具:探测、剪
13
13
 
14
14
  ## 兼容性
15
15
 
16
- 在 `@deepseek-ai/dsh@0.1.2-alpha.2` 上验证(2026-08-31)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
16
+ 已在官方 `@deepseek-ai/dsh@0.1.3-alpha.2` 下验证(2026-09-08):18 个组件同载,工具注册与调用契约全部通过。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
17
17
 
18
18
  ## 安装
19
19
 
@@ -83,9 +83,9 @@ ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
83
83
 
84
84
  ```bash
85
85
  pnpm install
86
- pnpm test # 构建 + 57 个测试(含真实 ffmpeg 端到端集成,缺 ffmpeg 自动跳过)
86
+ pnpm test # 构建 + 83 个测试(含真实 ffmpeg 端到端集成,缺 ffmpeg 自动跳过)
87
87
  ```
88
88
 
89
89
  ## License
90
90
 
91
- MIT
91
+ MIT
package/lib/args.js CHANGED
@@ -48,7 +48,12 @@ export const ENCODE_PRESETS = ['bilibili-1080p', 'bilibili-4k', 'vertical-1080p'
48
48
  const PRESET_TABLE = {
49
49
  'bilibili-1080p': { crf: 20, maxrate: '6000k', bufsize: '12000k' },
50
50
  'bilibili-4k': { crf: 18, maxrate: '20000k', bufsize: '40000k' },
51
- 'vertical-1080p': { crf: 20, maxrate: '6000k', bufsize: '12000k', vf: 'scale=-2:1920' },
51
+ 'vertical-1080p': {
52
+ crf: 20,
53
+ maxrate: '6000k',
54
+ bufsize: '12000k',
55
+ vf: 'scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1',
56
+ },
52
57
  'web-720p': { crf: 23, maxrate: '2800k', bufsize: '5600k', vf: 'scale=-2:720' },
53
58
  };
54
59
  /** 转码:预设 + 可选的 crf/fps/scale 覆盖。 */
package/lib/config.js CHANGED
@@ -12,22 +12,34 @@ const DEFAULT_GRACE_MS = 15000;
12
12
  * @throws 配置值非法时抛出中文错误。
13
13
  */
14
14
  export function resolveConfig(config, env = process.env) {
15
+ if (config !== undefined && config !== null && (typeof config !== 'object' || Array.isArray(config))) {
16
+ throw new Error('dsh-ffmpeg 配置必须是对象。');
17
+ }
15
18
  const cfg = config ?? {};
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');
19
+ if (cfg.ffmpegPath !== undefined && (typeof cfg.ffmpegPath !== 'string' || cfg.ffmpegPath.trim() === '')) {
20
+ throw new Error('ffmpegPath 必须是非空字符串。');
21
+ }
22
+ if (cfg.ffprobePath !== undefined && (typeof cfg.ffprobePath !== 'string' || cfg.ffprobePath.trim() === '')) {
23
+ throw new Error('ffprobePath 必须是非空字符串。');
24
+ }
25
+ if (cfg.overwrite !== undefined && typeof cfg.overwrite !== 'boolean') {
26
+ throw new Error('overwrite 必须是布尔值。');
27
+ }
28
+ const ffmpegPath = cfg.ffmpegPath?.trim() || env.DSH_FFMPEG_PATH?.trim() || 'ffmpeg';
29
+ const ffprobePath = cfg.ffprobePath?.trim() || env.DSH_FFPROBE_PATH?.trim() || 'ffprobe';
18
30
  let timeoutMs = DEFAULT_TIMEOUT_MS;
19
31
  if (cfg.timeoutMs !== undefined) {
20
- if (typeof cfg.timeoutMs !== 'number' || !Number.isFinite(cfg.timeoutMs) || cfg.timeoutMs <= 0) {
21
- throw new Error('timeoutMs 必须是大于 0 的数字(毫秒),例如 300000。');
32
+ if (typeof cfg.timeoutMs !== 'number' || !Number.isInteger(cfg.timeoutMs) || cfg.timeoutMs < 10000 || cfg.timeoutMs > 2 * 60 * 60 * 1000) {
33
+ throw new Error('timeoutMs 必须是 10000–7200000 的整数(毫秒)。');
22
34
  }
23
- timeoutMs = Math.min(2 * 60 * 60 * 1000, Math.max(10000, Math.round(cfg.timeoutMs)));
35
+ timeoutMs = cfg.timeoutMs;
24
36
  }
25
37
  let graceMs = DEFAULT_GRACE_MS;
26
38
  if (cfg.graceMs !== undefined) {
27
- if (typeof cfg.graceMs !== 'number' || !Number.isFinite(cfg.graceMs) || cfg.graceMs <= 0) {
28
- throw new Error('graceMs 必须是大于 0 的数字(毫秒),例如 15000。');
39
+ if (typeof cfg.graceMs !== 'number' || !Number.isInteger(cfg.graceMs) || cfg.graceMs < 1000 || cfg.graceMs > 120000) {
40
+ throw new Error('graceMs 必须是 1000–120000 的整数(毫秒)。');
29
41
  }
30
- graceMs = Math.min(120000, Math.max(1000, Math.round(cfg.graceMs)));
42
+ graceMs = cfg.graceMs;
31
43
  }
32
44
  const overwrite = cfg.overwrite === true;
33
45
  return { ffmpegPath, ffprobePath, timeoutMs, graceMs, overwrite };
package/lib/exec.d.ts CHANGED
@@ -15,6 +15,7 @@ export interface RunResult {
15
15
  export interface ProcessRunner {
16
16
  run(argv: readonly string[], options?: {
17
17
  timeoutMs?: number;
18
+ signal?: AbortSignal;
18
19
  }): Promise<RunResult>;
19
20
  }
20
21
  /** 工具所需的 ctx.subprocess 最小面。 */
package/lib/exec.js CHANGED
@@ -15,6 +15,9 @@ export function createSubprocessRunner(spawn, graceMs, defaultTimeoutMs) {
15
15
  const timeoutMs = options?.timeoutMs ?? defaultTimeoutMs;
16
16
  const controller = new AbortController();
17
17
  const timer = setTimeout(() => controller.abort(new Error('ffmpeg operation timed out')), timeoutMs);
18
+ const signal = options?.signal === undefined
19
+ ? controller.signal
20
+ : AbortSignal.any([options.signal, controller.signal]);
18
21
  let handle;
19
22
  try {
20
23
  handle = spawn({
@@ -26,7 +29,7 @@ export function createSubprocessRunner(spawn, graceMs, defaultTimeoutMs) {
26
29
  stderr: { maxBytes: COLLECT_BYTES },
27
30
  },
28
31
  graceMs,
29
- signal: controller.signal,
32
+ signal,
30
33
  });
31
34
  const outcome = await handle.done;
32
35
  const stdout = handle.collected.stdout?.readFrom(0).text ?? '';
package/lib/index.js CHANGED
@@ -20,14 +20,7 @@ export const inject = ['subprocess', 'tools'];
20
20
  * @param config - 插件配置(可缺省)。
21
21
  */
22
22
  export function apply(ctx, config) {
23
- let cfg;
24
- try {
25
- cfg = resolveConfig(config);
26
- }
27
- catch (error) {
28
- console.warn('[dsh-ffmpeg] ' + (error instanceof Error ? error.message : String(error)));
29
- cfg = resolveConfig(null);
30
- }
23
+ const cfg = resolveConfig(config);
31
24
  const runner = createSubprocessRunner(ctx.subprocess.spawn, cfg.graceMs, cfg.timeoutMs);
32
25
  const disposers = [];
33
26
  for (const definition of buildFfmpegTools(cfg, runner)) {
package/lib/tools.d.ts CHANGED
@@ -12,6 +12,10 @@ export interface ContentBlock {
12
12
  type: 'text';
13
13
  text: string;
14
14
  }
15
+ /** v0.1.2-rc.1 工具执行上下文中本插件需要的公共最小面。 */
16
+ export interface FfmpegToolRunContext {
17
+ readonly signal: AbortSignal;
18
+ }
15
19
  /** 注册给 ctx.tools.register 的原始工具定义。 */
16
20
  export interface FfmpegToolDefinition {
17
21
  name: string;
@@ -25,7 +29,7 @@ export interface FfmpegToolDefinition {
25
29
  schema: Record<string, unknown>;
26
30
  render(args: unknown, value: unknown): ContentBlock[];
27
31
  };
28
- execute(args: unknown, exec: unknown): Promise<unknown>;
32
+ execute(args: unknown, exec: FfmpegToolRunContext): Promise<unknown>;
29
33
  timeoutMs?: number;
30
34
  }
31
35
  /** 生成一行人类可读的媒体摘要:容器、时长、主视频、帧率、码率、体积。 */
package/lib/tools.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * @module dsh-ffmpeg/tools
6
6
  */
7
- import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
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
10
  import { basename, dirname, extname, join } from 'node:path';
@@ -65,8 +65,8 @@ function stringArray(args, key) {
65
65
  return value.filter((item) => typeof item === 'string' && item.trim() !== '').map((item) => item.trim());
66
66
  }
67
67
  /** 执行并检查退出码;非零抛中文错误(附 stderr 尾部)。 */
68
- async function runChecked(runner, argv, timeoutMs, label) {
69
- const result = await runner.run(argv, { timeoutMs });
68
+ async function runChecked(runner, argv, timeoutMs, label, signal) {
69
+ const result = await runner.run(argv, { timeoutMs, ...(signal === undefined ? {} : { signal }) });
70
70
  if (result.exitCode !== 0) {
71
71
  const tail = result.stderr.trim().split(/\r?\n/).slice(-6).join(' | ');
72
72
  throw new Error(label + '失败(退出码 ' + String(result.exitCode ?? 'null') + (result.signal ? ',信号 ' + result.signal : '') + '):' + (tail || '无错误输出'));
@@ -90,7 +90,7 @@ const audioStreamSchema = {
90
90
  };
91
91
  const subtitleStreamSchema = {
92
92
  type: 'object',
93
- properties: { codec: { type: 'string' }, language: { type: 'string' } },
93
+ properties: { codec: { type: 'string' }, language: { oneOf: [{ type: 'string' }, { type: 'null' }] } },
94
94
  additionalProperties: true,
95
95
  };
96
96
  const probeSchema = {
@@ -103,7 +103,7 @@ const probeSchema = {
103
103
  durationSeconds: { oneOf: [{ type: 'number' }, { type: 'null' }] },
104
104
  sizeBytes: { oneOf: [{ type: 'number' }, { type: 'null' }] },
105
105
  bitrate: { oneOf: [{ type: 'number' }, { type: 'null' }] },
106
- video: videoStreamSchema,
106
+ video: { oneOf: [videoStreamSchema, { type: 'null' }] },
107
107
  videos: { type: 'array', items: videoStreamSchema },
108
108
  audio: { type: 'array', items: audioStreamSchema },
109
109
  subtitles: { type: 'array', items: subtitleStreamSchema },
@@ -177,10 +177,10 @@ export function buildFfmpegTools(config, runner) {
177
177
  return lines;
178
178
  }),
179
179
  },
180
- async execute(rawArgs) {
180
+ async execute(rawArgs, exec) {
181
181
  const args = asRecord(rawArgs);
182
182
  const input = assertInputFile(requiredString(args, 'input', '输入文件'));
183
- const result = await runChecked(runner, probeArgs(cfg.ffprobePath, input), Math.min(timeout, 60000), 'ffprobe');
183
+ const result = await runChecked(runner, probeArgs(cfg.ffprobePath, input), Math.min(timeout, 60000), 'ffprobe', exec?.signal);
184
184
  const media = parseProbeJson(result.stdout);
185
185
  return { ok: true, input, summary: buildProbeSummary(media), ...media };
186
186
  },
@@ -204,7 +204,7 @@ export function buildFfmpegTools(config, runner) {
204
204
  return ['剪辑完成:' + rec.output + '(' + fmtSeconds(Number(rec.duration ?? 0)) + ' 秒' + (rec.reencode === true ? ',已重编码' : ',流拷贝') + ')'];
205
205
  }),
206
206
  },
207
- async execute(rawArgs) {
207
+ async execute(rawArgs, exec) {
208
208
  const args = asRecord(rawArgs);
209
209
  const input = assertInputFile(requiredString(args, 'input', '输入文件'));
210
210
  const start = optionalTime(args, 'start') ?? 0;
@@ -222,7 +222,7 @@ export function buildFfmpegTools(config, runner) {
222
222
  }
223
223
  const reencode = args.reencode === true;
224
224
  const output = resolveOutputPath(input, optionalString(args, 'output'), '.cut', extname(input) || '.mp4', cfg.overwrite);
225
- await runChecked(runner, cutArgs(cfg.ffmpegPath, { input, start, duration, output, overwrite: cfg.overwrite, reencode }), timeout, 'ffmpeg 剪辑');
225
+ await runChecked(runner, cutArgs(cfg.ffmpegPath, { input, start, duration, output, overwrite: cfg.overwrite, reencode }), timeout, 'ffmpeg 剪辑', exec?.signal);
226
226
  return { output, start, duration, reencode };
227
227
  },
228
228
  timeoutMs: timeout,
@@ -242,7 +242,7 @@ export function buildFfmpegTools(config, runner) {
242
242
  return ['拼接完成:' + rec.output + '(' + rec.count + ' 个片段' + (rec.reencode === true ? ',已重编码' : ',流拷贝') + ')'];
243
243
  }),
244
244
  },
245
- async execute(rawArgs) {
245
+ async execute(rawArgs, exec) {
246
246
  const args = asRecord(rawArgs);
247
247
  const inputs = stringArray(args, 'inputs');
248
248
  if (inputs.length < 2)
@@ -257,14 +257,14 @@ export function buildFfmpegTools(config, runner) {
257
257
  const listPath = join(tmpdir(), 'dsh-ffmpeg-concat-' + Date.now() + '-' + randomUUID().slice(0, 8) + '.txt');
258
258
  writeFileSync(listPath, concatListContent(absolute), 'utf8');
259
259
  try {
260
- await runChecked(runner, concatArgs(cfg.ffmpegPath, { inputs: absolute, listFilePath: listPath, output, overwrite: cfg.overwrite, reencode: false }), timeout, 'ffmpeg 拼接');
260
+ await runChecked(runner, concatArgs(cfg.ffmpegPath, { inputs: absolute, listFilePath: listPath, output, overwrite: cfg.overwrite, reencode: false }), timeout, 'ffmpeg 拼接', exec?.signal);
261
261
  }
262
262
  finally {
263
263
  rmSync(listPath, { force: true });
264
264
  }
265
265
  }
266
266
  else {
267
- await runChecked(runner, concatArgs(cfg.ffmpegPath, { inputs: absolute, output, overwrite: cfg.overwrite, reencode: true }), timeout, 'ffmpeg 拼接');
267
+ await runChecked(runner, concatArgs(cfg.ffmpegPath, { inputs: absolute, output, overwrite: cfg.overwrite, reencode: true }), timeout, 'ffmpeg 拼接', exec?.signal);
268
268
  }
269
269
  return { output, count: absolute.length, reencode };
270
270
  },
@@ -288,7 +288,7 @@ export function buildFfmpegTools(config, runner) {
288
288
  return ['转码完成:' + rec.output + '(预设 ' + rec.preset + ',crf=' + rec.crf + ')'];
289
289
  }),
290
290
  },
291
- async execute(rawArgs) {
291
+ async execute(rawArgs, exec) {
292
292
  const args = asRecord(rawArgs);
293
293
  const input = assertInputFile(requiredString(args, 'input', '输入文件'));
294
294
  const presetRaw = optionalString(args, 'preset') ?? 'bilibili-1080p';
@@ -318,7 +318,7 @@ export function buildFfmpegTools(config, runner) {
318
318
  scale = scaleRaw;
319
319
  }
320
320
  const output = resolveOutputPath(input, optionalString(args, 'output'), '.encoded', extname(input) || '.mp4', cfg.overwrite);
321
- await runChecked(runner, encodeArgs(cfg.ffmpegPath, { input, output, preset, crf, fps, scale, overwrite: cfg.overwrite }), timeout, 'ffmpeg 转码');
321
+ await runChecked(runner, encodeArgs(cfg.ffmpegPath, { input, output, preset, crf, fps, scale, overwrite: cfg.overwrite }), timeout, 'ffmpeg 转码', exec?.signal);
322
322
  return { output, preset, crf: crf ?? 'preset', fps: fps ?? null, scale: scale ?? null };
323
323
  },
324
324
  timeoutMs: timeout,
@@ -338,12 +338,12 @@ export function buildFfmpegTools(config, runner) {
338
338
  return ['字幕烧录完成:' + rec.output + '(硬字幕)'];
339
339
  }),
340
340
  },
341
- async execute(rawArgs) {
341
+ async execute(rawArgs, exec) {
342
342
  const args = asRecord(rawArgs);
343
343
  const input = assertInputFile(requiredString(args, 'input', '输入视频'));
344
344
  const subtitlePath = assertInputFile(requiredString(args, 'subtitle', '字幕文件'));
345
345
  const output = resolveOutputPath(input, optionalString(args, 'output'), '.sub', extname(input) || '.mp4', cfg.overwrite);
346
- await runChecked(runner, subtitleArgs(cfg.ffmpegPath, { input, subtitle: subtitlePath, output, overwrite: cfg.overwrite }), timeout, 'ffmpeg 字幕');
346
+ await runChecked(runner, subtitleArgs(cfg.ffmpegPath, { input, subtitle: subtitlePath, output, overwrite: cfg.overwrite }), timeout, 'ffmpeg 字幕', exec?.signal);
347
347
  return { output, mode: 'burn' };
348
348
  },
349
349
  timeoutMs: timeout,
@@ -367,7 +367,7 @@ export function buildFfmpegTools(config, runner) {
367
367
  return ['提取完成(' + rec.what + '):' + rec.output];
368
368
  }),
369
369
  },
370
- async execute(rawArgs) {
370
+ async execute(rawArgs, exec) {
371
371
  const args = asRecord(rawArgs);
372
372
  const input = assertInputFile(requiredString(args, 'input', '输入文件'));
373
373
  const what = requiredString(args, 'what', '提取内容');
@@ -403,7 +403,7 @@ export function buildFfmpegTools(config, runner) {
403
403
  output = join(dirname(input), sanitizeName(basename(input, extname(input))) + '-%03d.png');
404
404
  }
405
405
  }
406
- await runChecked(runner, extractArgs(cfg.ffmpegPath, { input, what: what, output, overwrite: cfg.overwrite, start, duration, fps, streamIndex }), timeout, 'ffmpeg 提取');
406
+ await runChecked(runner, extractArgs(cfg.ffmpegPath, { input, what: what, output, overwrite: cfg.overwrite, start, duration, fps, streamIndex }), timeout, 'ffmpeg 提取', exec?.signal);
407
407
  return { output, what, start: start ?? null, duration: duration ?? null, fps: fps ?? null, streamIndex };
408
408
  },
409
409
  timeoutMs: timeout,
@@ -426,7 +426,7 @@ export function buildFfmpegTools(config, runner) {
426
426
  return ['GIF 生成完成:' + rec.output + '(' + rec.width + 'px,' + rec.fps + 'fps,' + fmtSeconds(Number(rec.duration ?? 0)) + ' 秒)'];
427
427
  }),
428
428
  },
429
- async execute(rawArgs) {
429
+ async execute(rawArgs, exec) {
430
430
  const args = asRecord(rawArgs);
431
431
  const input = assertInputFile(requiredString(args, 'input', '输入视频'));
432
432
  const start = optionalTime(args, 'start') ?? 0;
@@ -438,14 +438,17 @@ export function buildFfmpegTools(config, runner) {
438
438
  const widthRaw = args.width;
439
439
  const width = typeof widthRaw === 'number' && Number.isInteger(widthRaw) ? Math.min(1280, Math.max(64, widthRaw)) : 480;
440
440
  const output = resolveOutputPath(input, optionalString(args, 'output'), '.gif', '.gif', cfg.overwrite);
441
- const palettePath = output + '.palette.png';
441
+ // 调色板属于内部临时产物,不能借用用户输出旁的可预测路径;否则 -y 与
442
+ // finally 清理都可能覆盖/删除用户原有的 <output>.palette.png。
443
+ const paletteDir = mkdtempSync(join(tmpdir(), 'dsh-ffmpeg-gif-'));
444
+ const palettePath = join(paletteDir, 'palette.png');
442
445
  const spec = { input, output, palettePath, overwrite: cfg.overwrite, start, duration, fps, width };
443
446
  try {
444
- await runChecked(runner, gifPaletteArgs(cfg.ffmpegPath, spec), timeout, 'ffmpeg GIF 调色板');
445
- await runChecked(runner, gifUseArgs(cfg.ffmpegPath, spec), timeout, 'ffmpeg GIF 合成');
447
+ await runChecked(runner, gifPaletteArgs(cfg.ffmpegPath, spec), timeout, 'ffmpeg GIF 调色板', exec?.signal);
448
+ await runChecked(runner, gifUseArgs(cfg.ffmpegPath, spec), timeout, 'ffmpeg GIF 合成', exec?.signal);
446
449
  }
447
450
  finally {
448
- rmSync(palettePath, { force: true });
451
+ rmSync(paletteDir, { recursive: true, force: true });
449
452
  }
450
453
  return { output, start, duration, fps, width };
451
454
  },
@@ -469,7 +472,7 @@ export function buildFfmpegTools(config, runner) {
469
472
  return ['抽帧完成:共 ' + rec.count + ' 张,输出目录 ' + rec.outputDir + (rec.mode === 'times' ? '(指定时间点)' : '(每 ' + rec.every + ' 秒一帧)')];
470
473
  }),
471
474
  },
472
- async execute(rawArgs) {
475
+ async execute(rawArgs, exec) {
473
476
  const args = asRecord(rawArgs);
474
477
  const input = assertInputFile(requiredString(args, 'input', '输入文件'));
475
478
  const formatRaw = optionalString(args, 'format')?.toLowerCase() ?? 'png';
@@ -488,7 +491,7 @@ export function buildFfmpegTools(config, runner) {
488
491
  if (at === null)
489
492
  throw new Error('times 里第 ' + (i + 1) + ' 个时间点非法:' + raw + '(请用秒数或 HH:MM:SS.mmm)。');
490
493
  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 定点抽帧');
494
+ await runChecked(runner, frameAtArgs(cfg.ffmpegPath, { input, time: at, output: target, overwrite: cfg.overwrite }), timeout, 'ffmpeg 定点抽帧', exec?.signal);
492
495
  i++;
493
496
  }
494
497
  }
@@ -499,7 +502,7 @@ export function buildFfmpegTools(config, runner) {
499
502
  const maxFramesRaw = args.maxFrames;
500
503
  const maxFrames = typeof maxFramesRaw === 'number' && Number.isInteger(maxFramesRaw) ? Math.min(500, Math.max(1, maxFramesRaw)) : 100;
501
504
  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 抽帧');
505
+ await runChecked(runner, extractArgs(cfg.ffmpegPath, { input, what: 'frames', output: pattern, overwrite: cfg.overwrite, fps: 1 / every, streamIndex: 0, maxFrames }), timeout, 'ffmpeg 抽帧', exec?.signal);
503
506
  }
504
507
  const files = readdirSync(outDir).filter((f) => f.startsWith('frame-') && f.endsWith(ext)).sort();
505
508
  const every = optionalNumber(args, 'every') ?? 1;
@@ -526,7 +529,7 @@ export function buildFfmpegTools(config, runner) {
526
529
  return ['调整完成:' + rec.output + '(' + ops + ')'];
527
530
  }),
528
531
  },
529
- async execute(rawArgs) {
532
+ async execute(rawArgs, exec) {
530
533
  const args = asRecord(rawArgs);
531
534
  const input = assertInputFile(requiredString(args, 'input', '输入文件'));
532
535
  const speed = optionalNumber(args, 'speed');
@@ -548,10 +551,10 @@ export function buildFfmpegTools(config, runner) {
548
551
  throw new Error('speed / volume / mute / rotate 至少提供一个。');
549
552
  }
550
553
  // 先探测:确认有没有音轨,避免对无声文件构建音频滤镜报错
551
- const probeResult = await runChecked(runner, probeArgs(cfg.ffprobePath, input), Math.min(timeout, 60000), 'ffprobe');
554
+ const probeResult = await runChecked(runner, probeArgs(cfg.ffprobePath, input), Math.min(timeout, 60000), 'ffprobe', exec?.signal);
552
555
  const hasAudio = parseProbeJson(probeResult.stdout).audio.length > 0;
553
556
  const output = resolveOutputPath(input, optionalString(args, 'output'), '.adjust', extname(input) || '.mp4', cfg.overwrite);
554
- await runChecked(runner, adjustArgs(cfg.ffmpegPath, { input, output, overwrite: cfg.overwrite, speed, volume, mute, rotate, hasAudio }), timeout, 'ffmpeg 调整');
557
+ await runChecked(runner, adjustArgs(cfg.ffmpegPath, { input, output, overwrite: cfg.overwrite, speed, volume, mute, rotate, hasAudio }), timeout, 'ffmpeg 调整', exec?.signal);
555
558
  const ops = [];
556
559
  if (speed !== undefined)
557
560
  ops.push('倍速 x' + speed);
@@ -582,12 +585,12 @@ export function buildFfmpegTools(config, runner) {
582
585
  return lines;
583
586
  }),
584
587
  },
585
- async execute() {
588
+ async execute(_rawArgs, exec) {
586
589
  const checks = [];
587
590
  let ok = true;
588
591
  for (const [label, bin] of [['ffmpeg', cfg.ffmpegPath], ['ffprobe', cfg.ffprobePath]]) {
589
592
  try {
590
- const result = await runner.run([bin, '-version'], { timeoutMs: 15000 });
593
+ const result = await runner.run([bin, '-version'], { timeoutMs: 15000, ...(exec?.signal === undefined ? {} : { signal: exec.signal }) });
591
594
  const firstLine = result.stdout.split(/\r?\n/)[0]?.trim() ?? '';
592
595
  if (result.exitCode === 0) {
593
596
  checks.push({ name: label, ok: true, path: bin, version: firstLine });
@@ -598,6 +601,8 @@ export function buildFfmpegTools(config, runner) {
598
601
  }
599
602
  }
600
603
  catch (error) {
604
+ if (exec?.signal.aborted === true)
605
+ throw error;
601
606
  ok = false;
602
607
  checks.push({ name: label, ok: false, path: bin, detail: error instanceof Error ? error.message : String(error) });
603
608
  }
package/package.json CHANGED
@@ -1,60 +1,60 @@
1
- {
2
- "name": "dsh-ffmpeg",
3
- "version": "0.4.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
- }
1
+ {
2
+ "name": "dsh-ffmpeg",
3
+ "version": "0.4.1",
4
+ "description": "DSH 视频处理工具插件:ffmpeg_probe/cut/concat/encode/subtitle/extract/gif/frames/adjust/health 十工具(探测摘要、批量抽帧、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
+ }