dsh-ffmpeg 0.4.2 → 0.4.3

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
@@ -51,10 +51,10 @@ Override the plugin row in your profile's `cordis.patch.yml` (defaults apply whe
51
51
  | :-- | :-- | :-- |
52
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
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) | `inputs` array required |
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
55
  | `ffmpeg_encode` | Transcode with presets (bilibili 1080p/4K, vertical 1080p, web-720p) plus crf/fps/scale overrides | `input` required; `preset` optional |
56
56
  | `ffmpeg_subtitle` | Burn subtitles (SRT/ASS hard subs) | `input` + `subtitle` required |
57
- | `ffmpeg_extract` | Extract audio (m4a) / frame sequences / single frame / subtitle stream | `input` + `what` required |
57
+ | `ffmpeg_extract` | Extract audio (m4a; non-AAC is auto-transcoded to AAC) / frame sequences / single frame / subtitle stream | `input` + `what` required |
58
58
  | `ffmpeg_gif` | Video to high-quality GIF (two-pass palette) | `input` required; `fps`/`width`/`duration` optional |
59
59
 
60
60
  ### Examples
@@ -64,6 +64,7 @@ ffmpeg_probe { input: E:\videos\raw.mp4 }
64
64
  ffmpeg_cut { input: E:\videos\raw.mp4, start: 10, end: 30 }
65
65
  ffmpeg_encode { input: E:\videos\raw.mp4, preset: bilibili-1080p }
66
66
  ffmpeg_subtitle { input: E:\videos\raw.mp4, subtitle: E:\videos\subs.srt }
67
+ ffmpeg_extract { input: E:\videos\raw.webm, what: audio }
67
68
  ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
68
69
  ```
69
70
 
@@ -79,9 +80,13 @@ ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
79
80
 
80
81
  ```bash
81
82
  pnpm install
82
- pnpm test # build + 83 tests, including a real-ffmpeg end-to-end suite (auto-skipped without ffmpeg)
83
+ pnpm test # build + 102 tests, including a real-ffmpeg end-to-end suite (auto-skipped without ffmpeg)
83
84
  ```
84
85
 
85
86
  ## License
86
87
 
87
88
  MIT
89
+
90
+ ## Changelog
91
+
92
+ - **0.4.3 (2026-09-18)**: 修复抽帧数到旧帧/清单截断、取消被当成功、取消文案丢原因、`+2dB` 被拒; 音轨提取非 AAC 自动转 AAC、concat 混合音轨可用、ffprobe 截断明确报错. 测试 102 项.
package/README.md CHANGED
@@ -54,10 +54,10 @@ dsh plugin --profile web remove dsh-ffmpeg
54
54
  | :-- | :-- | :-- |
55
55
  | `ffmpeg_probe` | 探测媒体信息(格式/时长/分辨率/帧率/码率/音轨/字幕轨;多视频流时返回完整 videos 列表) | `input` 必填 |
56
56
  | `ffmpeg_cut` | 剪辑片段(默认流拷贝秒级,可精确重编码) | `input` 必填;`start`/`end`/`duration` |
57
- | `ffmpeg_concat` | 拼接 2-20 个片段(同编码流拷贝 / 混合编码重编码) | `inputs` 数组必填 |
57
+ | `ffmpeg_concat` | 拼接 2-20 个片段(同编码流拷贝 / 混合编码重编码;无音轨输入按纯视频拼接) | `inputs` 数组必填 |
58
58
  | `ffmpeg_encode` | 转码(B 站 1080p/4K、竖屏 1080p、web-720p 预设 + crf/fps/scale 覆盖) | `input` 必填;`preset` 可选 |
59
59
  | `ffmpeg_subtitle` | 字幕烧录(SRT/ASS 硬字幕) | `input`+`subtitle` 必填 |
60
- | `ffmpeg_extract` | 提取音轨(m4a)/ 抽帧序列 / 单帧 / 字幕流 | `input`+`what` 必填 |
60
+ | `ffmpeg_extract` | 提取音轨(m4a,非 AAC 自动转 AAC)/ 抽帧序列 / 单帧 / 字幕流 | `input`+`what` 必填 |
61
61
  | `ffmpeg_gif` | 视频转高质量 GIF(两遍调色板) | `input` 必填;`fps`/`width`/`duration` 可选 |
62
62
  | `ffmpeg_frames` | 批量抽帧给视觉模型读图(定间隔或指定时间点,PNG/JPG) | `input` 必填;`every`/`times` 二选一 |
63
63
  | `ffmpeg_adjust` | 变速(音视频同步)/ 音量 / 静音 / 旋转(竖横屏互转),可组合 | `input` 必填;`speed`/`volume`/`mute`/`rotate` 至少一个 |
@@ -70,7 +70,9 @@ ffmpeg_probe { input: E:\videos\raw.mp4 }
70
70
  ffmpeg_cut { input: E:\videos\raw.mp4, start: 10, end: 30 }
71
71
  ffmpeg_encode { input: E:\videos\raw.mp4, preset: bilibili-1080p }
72
72
  ffmpeg_subtitle { input: E:\videos\raw.mp4, subtitle: E:\videos\subs.srt }
73
+ ffmpeg_extract { input: E:\videos\raw.webm, what: audio }
73
74
  ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
75
+ ffmpeg_adjust { input: E:\videos\raw.mp4, volume: +2dB }
74
76
  ```
75
77
 
76
78
  ## 安全设计
@@ -85,9 +87,13 @@ ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
85
87
 
86
88
  ```bash
87
89
  pnpm install
88
- pnpm test # 构建 + 83 个测试(含真实 ffmpeg 端到端集成,缺 ffmpeg 自动跳过)
90
+ pnpm test # 构建 + 102 个测试(含真实 ffmpeg 端到端集成,缺 ffmpeg 自动跳过)
89
91
  ```
90
92
 
91
93
  ## License
92
94
 
93
95
  MIT
96
+
97
+ ## 版本记录
98
+
99
+ - **0.4.3(2026-09-18)**:修复抽帧数到旧帧/清单截断、取消被当成功、取消文案丢原因、`+2dB` 被拒;音轨提取非 AAC 自动转 AAC、concat 混合音轨可用、ffprobe 截断明确报错。测试 102 项。
package/lib/args.d.ts CHANGED
@@ -26,6 +26,8 @@ export interface ConcatSpec {
26
26
  output: string;
27
27
  overwrite: boolean;
28
28
  reencode: boolean;
29
+ /** 所有输入都含音频流(probe 结果):只有全有才能用 a=1,否则 concat filter 绑定失败。 */
30
+ hasAudio?: boolean;
29
31
  }
30
32
  /** 拼接:同编码流拷贝走 concat demuxer(需 list 文件),否则 filter_complex 重编码。 */
31
33
  export declare function concatArgs(ffmpeg: string, spec: ConcatSpec): string[];
@@ -63,6 +65,8 @@ export interface ExtractSpec {
63
65
  fps?: number;
64
66
  streamIndex: number;
65
67
  maxFrames?: number;
68
+ /** 音频流拷贝失败后回退 AAC 重编码(tools 层在 AAC 容器里触发)。 */
69
+ transcodeAudio?: boolean;
66
70
  }
67
71
  /** 提取:音频(拷贝)/ 抽帧序列 / 单帧 / 字幕流。 */
68
72
  export declare function extractArgs(ffmpeg: string, spec: ExtractSpec): string[];
package/lib/args.js CHANGED
@@ -37,7 +37,11 @@ export function concatArgs(ffmpeg, spec) {
37
37
  const parts = [ffmpeg, flag];
38
38
  for (const input of spec.inputs)
39
39
  parts.push('-i', input);
40
- parts.push('-filter_complex', 'concat=n=' + spec.inputs.length + ':v=1:a=1', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-c:a', 'aac', spec.output);
40
+ parts.push('-filter_complex', 'concat=n=' + spec.inputs.length + ':v=1:a=' + (spec.hasAudio === false ? '0' : '1'));
41
+ // a=0 时 filter 没有音频 pad,但 ffmpeg 仍可能自动选中输入音轨;用 -an 明确只要视频
42
+ if (spec.hasAudio === false)
43
+ parts.push('-an');
44
+ parts.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-c:a', 'aac', spec.output);
41
45
  return parts;
42
46
  }
43
47
  /** concat demuxer 的 list 文件内容(路径中的单引号按 ffmpeg 规则转义)。 */
@@ -78,6 +82,9 @@ export function subtitleArgs(ffmpeg, spec) {
78
82
  export function extractArgs(ffmpeg, spec) {
79
83
  const flag = overwriteFlag(spec.overwrite);
80
84
  if (spec.what === 'audio') {
85
+ if (spec.transcodeAudio === true) {
86
+ return [ffmpeg, flag, '-i', spec.input, '-vn', '-c:a', 'aac', spec.output];
87
+ }
81
88
  return [ffmpeg, flag, '-i', spec.input, '-vn', '-c', 'copy', spec.output];
82
89
  }
83
90
  if (spec.what === 'subtitle') {
package/lib/exec.d.ts CHANGED
@@ -28,11 +28,15 @@ export interface SubprocessHandleLike {
28
28
  stdout?: {
29
29
  readFrom(offset: number): {
30
30
  text: string;
31
+ lossy?: boolean;
32
+ truncated?: boolean;
31
33
  };
32
34
  };
33
35
  stderr?: {
34
36
  readFrom(offset: number): {
35
37
  text: string;
38
+ lossy?: boolean;
39
+ truncated?: boolean;
36
40
  };
37
41
  };
38
42
  };
package/lib/exec.js CHANGED
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * @module dsh-ffmpeg/exec
6
6
  */
7
+ import { basename } from 'node:path';
7
8
  const COLLECT_BYTES = 4 * 1024 * 1024;
8
9
  /**
9
10
  * 用 DSH subprocess 服务构造 ProcessRunner:collect 模式收流,AbortSignal 驱动超时,
@@ -32,9 +33,17 @@ export function createSubprocessRunner(spawn, graceMs, defaultTimeoutMs) {
32
33
  signal,
33
34
  });
34
35
  const outcome = await handle.done;
35
- const stdout = handle.collected.stdout?.readFrom(0).text ?? '';
36
- const stderr = handle.collected.stderr?.readFrom(0).text ?? '';
37
- return { exitCode: outcome.exitCode, signal: outcome.signal, stdout, stderr };
36
+ // done 可能因竞态在取消之后才 resolve:以 signal 状态为准,不能当成功返回
37
+ if (signal.aborted)
38
+ throw signal.reason;
39
+ const stdoutRead = handle.collected.stdout?.readFrom(0);
40
+ const stderrRead = handle.collected.stderr?.readFrom(0);
41
+ // collect 上限 4MB,超出时宿主保尾丢头;ffprobe JSON 丢头必然残缺,必须在解析前点名截断
42
+ if (stdoutRead?.lossy === true || stdoutRead?.truncated === true) {
43
+ const program = basename(String(argv[0] ?? '')).replace(/\.exe$/i, '');
44
+ throw new Error(program + ' 输出超过 4MB 已截断(宿主仅保留尾部、丢弃头部),无法解析完整 JSON;请缩小探测范围或改用更小的输入后重试。');
45
+ }
46
+ return { exitCode: outcome.exitCode, signal: outcome.signal, stdout: stdoutRead?.text ?? '', stderr: stderrRead?.text ?? '' };
38
47
  }
39
48
  finally {
40
49
  clearTimeout(timer);
package/lib/tools.js CHANGED
@@ -64,9 +64,16 @@ function stringArray(args, key) {
64
64
  return [];
65
65
  return value.filter((item) => typeof item === 'string' && item.trim() !== '').map((item) => item.trim());
66
66
  }
67
+ /** 已取消则抛出取消原因;作为 await 前后的统一取消检查。 */
68
+ function throwIfAborted(signal) {
69
+ if (signal?.aborted === true)
70
+ throw signal.reason;
71
+ }
67
72
  /** 执行并检查退出码;非零抛中文错误(附 stderr 尾部)。 */
68
73
  async function runChecked(runner, argv, timeoutMs, label, signal) {
74
+ throwIfAborted(signal);
69
75
  const result = await runner.run(argv, { timeoutMs, ...(signal === undefined ? {} : { signal }) });
76
+ throwIfAborted(signal);
70
77
  if (result.exitCode !== 0) {
71
78
  const tail = result.stderr.trim().split(/\r?\n/).slice(-6).join(' | ');
72
79
  throw new Error(label + '失败(退出码 ' + String(result.exitCode ?? 'null') + (result.signal ? ',信号 ' + result.signal : '') + '):' + (tail || '无错误输出'));
@@ -145,6 +152,8 @@ export function buildProbeSummary(media) {
145
152
  parts.push('音频流 ' + media.audio.length + ' / 字幕流 ' + media.subtitles.length);
146
153
  return parts.join(',');
147
154
  }
155
+ /** 只接受 AAC 音轨的常见输出容器:音频流拷贝失败时回退转码。 */
156
+ const AAC_CONTAINER_EXTS = ['.m4a', '.mp4', '.mov', '.m4v', '.3gp', '.3g2', '.aac'];
148
157
  // ---------- 工具构建 ----------
149
158
  /**
150
159
  * 构建七个工具定义。
@@ -229,7 +238,7 @@ export function buildFfmpegTools(config, runner) {
229
238
  };
230
239
  const concat = {
231
240
  name: 'ffmpeg_concat',
232
- description: '拼接多个视频片段。默认要求编码一致(流拷贝,秒级完成);reencode=true 时任意格式统一重编码拼接(慢)。inputs 为 2-20 个文件路径。',
241
+ description: '拼接多个视频片段。默认要求编码一致(流拷贝,秒级完成);reencode=true 时统一重编码拼接(慢;先 probe 各输入,任一输入无音轨就按纯视频拼接,避免音轨绑定失败)。inputs 为 2-20 个文件路径。',
233
242
  parameters: compileParameters({
234
243
  inputs: { type: 'array', items: { type: 'string' }, required: true, description: '输入文件路径数组(2-20 个,必填)。' },
235
244
  output: { type: 'string', description: '输出路径(可选,默认第一个输入同目录加 .concat 后缀)。' },
@@ -264,7 +273,16 @@ export function buildFfmpegTools(config, runner) {
264
273
  }
265
274
  }
266
275
  else {
267
- await runChecked(runner, concatArgs(cfg.ffmpegPath, { inputs: absolute, output, overwrite: cfg.overwrite, reencode: true }), timeout, 'ffmpeg 拼接', exec?.signal);
276
+ // concat filter 的 a=1 要求每个输入都含音频轨,否则报 Error binding filtergraph inputs/outputs;先 probe 确认
277
+ let allHaveAudio = true;
278
+ for (const inputPath of absolute) {
279
+ const probeResult = await runChecked(runner, probeArgs(cfg.ffprobePath, inputPath), Math.min(timeout, 60000), 'ffprobe', exec?.signal);
280
+ if (parseProbeJson(probeResult.stdout).audio.length === 0) {
281
+ allHaveAudio = false;
282
+ break;
283
+ }
284
+ }
285
+ await runChecked(runner, concatArgs(cfg.ffmpegPath, { inputs: absolute, output, overwrite: cfg.overwrite, reencode: true, hasAudio: allHaveAudio }), timeout, 'ffmpeg 拼接', exec?.signal);
268
286
  }
269
287
  return { output, count: absolute.length, reencode };
270
288
  },
@@ -350,7 +368,7 @@ export function buildFfmpegTools(config, runner) {
350
368
  };
351
369
  const extract = {
352
370
  name: 'ffmpeg_extract',
353
- description: '提取媒体成分。what=audio 提取音轨(流拷贝 m4a);what=frames 按 fps 抽帧序列(输出为含 %03d 的 PNG 序列);what=frame 抽单帧(start 时刻,默认首帧);what=subtitle 提取字幕流(streamIndex 默认 0)。',
371
+ description: '提取媒体成分。what=audio 提取音轨(默认 .m4a:AAC 流拷贝,非 AAC 自动回退转码为 AAC);what=frames 按 fps 抽帧序列(输出为含 %03d 的 PNG 序列);what=frame 抽单帧(start 时刻,默认首帧);what=subtitle 提取字幕流(streamIndex 默认 0)。',
354
372
  parameters: compileParameters({
355
373
  input: { type: 'string', required: true, description: '输入文件(必填)。' },
356
374
  what: { type: 'string', required: true, description: '提取内容:audio / frames / frame / subtitle(必填)。' },
@@ -403,7 +421,24 @@ export function buildFfmpegTools(config, runner) {
403
421
  output = join(dirname(input), sanitizeName(basename(input, extname(input))) + '-%03d.png');
404
422
  }
405
423
  }
406
- await runChecked(runner, extractArgs(cfg.ffmpegPath, { input, what: what, output, overwrite: cfg.overwrite, start, duration, fps, streamIndex }), timeout, 'ffmpeg 提取', exec?.signal);
424
+ const spec = { input, what: what, output, overwrite: cfg.overwrite, start, duration, fps, streamIndex };
425
+ try {
426
+ await runChecked(runner, extractArgs(cfg.ffmpegPath, spec), timeout, 'ffmpeg 提取', exec?.signal);
427
+ }
428
+ catch (error) {
429
+ throwIfAborted(exec?.signal);
430
+ if (what !== 'audio')
431
+ throw error;
432
+ const detail = error instanceof Error ? error.message : String(error);
433
+ // 只有「编码不被容器支持」才值得换 AAC;超时/取消/权限等原样抛出,避免重试一次翻倍
434
+ if (!/not currently supported in container|Could not find tag for codec/i.test(detail))
435
+ throw error;
436
+ if (!AAC_CONTAINER_EXTS.includes(extname(output).toLowerCase())) {
437
+ throw new Error('ffmpeg 提取失败:音频编码无法原样复制到 ' + (extname(output) || '该') + ' 容器。请把 output 改成 .m4a(会自动转码为 AAC),或使用与输入音轨匹配的输出扩展名。原始错误:' + detail);
438
+ }
439
+ // m4a 等 AAC 容器不支持非 AAC 流拷贝:回退 -c:a aac;首次失败可能留下半成品,用 -y 覆盖
440
+ await runChecked(runner, extractArgs(cfg.ffmpegPath, { ...spec, overwrite: true, transcodeAudio: true }), timeout, 'ffmpeg 提取(音轨转 AAC)', exec?.signal);
441
+ }
407
442
  return { output, what, start: start ?? null, duration: duration ?? null, fps: fps ?? null, streamIndex };
408
443
  },
409
444
  timeoutMs: timeout,
@@ -456,13 +491,13 @@ export function buildFfmpegTools(config, runner) {
456
491
  };
457
492
  const frames = {
458
493
  name: 'ffmpeg_frames',
459
- description: '从视频批量抽帧为图片(PNG/JPG)。两种模式:every(固定秒间隔抽帧,如 every=2 表示每 2 秒一帧,默认 1)或 times(指定时间点列表,如 ["00:00:05","00:01:30"],最多 20 个)。maxFrames 限制 every 模式的帧数上限(1-500,默认 100)。返回输出目录、文件清单与数量,便于后续视觉模型读图。',
494
+ description: '从视频批量抽帧为图片(PNG/JPG)。两种模式:every(固定秒间隔抽帧,如 every=2 表示每 2 秒一帧,默认 1)或 times(指定时间点列表,如 ["00:00:05","00:01:30"],最多 20 个)。maxFrames 限制 every 模式的帧数上限(1-500,默认 100)。每次运行在 outputDir 下新建独立 run-* 子目录,只返回本次运行的输出目录、文件清单与数量,便于后续视觉模型读图。',
460
495
  parameters: compileParameters({
461
496
  input: { type: 'string', required: true, description: '输入视频(必填)。' },
462
497
  every: { type: 'number', description: '抽帧间隔秒数(与 times 二选一,默认 1)。' },
463
498
  times: { type: 'array', items: { type: 'string' }, description: '时间点列表(与 every 二选一,最多 20 个,秒数或 HH:MM:SS.mmm)。' },
464
499
  maxFrames: { type: 'integer', description: 'every 模式帧数上限 1-500(默认 100)。' },
465
- outputDir: { type: 'string', description: '输出目录(可选,默认输入同目录 <文件名>-frames)。' },
500
+ outputDir: { type: 'string', description: '输出目录(可选,默认输入同目录 <文件名>-frames);每次运行会在其中新建 run-* 独立子目录。' },
466
501
  format: { type: 'string', description: '图片格式:png(默认)或 jpg。' },
467
502
  }),
468
503
  output: {
@@ -482,6 +517,8 @@ export function buildFfmpegTools(config, runner) {
482
517
  const times = stringArray(args, 'times');
483
518
  const outDir = optionalString(args, 'outputDir') ?? join(dirname(input), sanitizeName(basename(input, extname(input))) + '-frames');
484
519
  mkdirSync(outDir, { recursive: true });
520
+ // 每次运行独占 run-* 子目录:历史 frame-* 不参与计数,也不会被本次 -y 覆盖
521
+ const runDir = mkdtempSync(join(outDir, 'run-'));
485
522
  if (times.length > 0) {
486
523
  if (times.length > 20)
487
524
  throw new Error('times 最多 20 个时间点(当前 ' + times.length + ' 个)。');
@@ -490,7 +527,7 @@ export function buildFfmpegTools(config, runner) {
490
527
  const at = parseTimeArg(raw);
491
528
  if (at === null)
492
529
  throw new Error('times 里第 ' + (i + 1) + ' 个时间点非法:' + raw + '(请用秒数或 HH:MM:SS.mmm)。');
493
- const target = join(outDir, 'frame-' + String(i + 1).padStart(3, '0') + ext);
530
+ const target = join(runDir, 'frame-' + String(i + 1).padStart(3, '0') + ext);
494
531
  await runChecked(runner, frameAtArgs(cfg.ffmpegPath, { input, time: at, output: target, overwrite: cfg.overwrite }), timeout, 'ffmpeg 定点抽帧', exec?.signal);
495
532
  i++;
496
533
  }
@@ -501,12 +538,12 @@ export function buildFfmpegTools(config, runner) {
501
538
  throw new Error('every 必须大于 0。');
502
539
  const maxFramesRaw = args.maxFrames;
503
540
  const maxFrames = typeof maxFramesRaw === 'number' && Number.isInteger(maxFramesRaw) ? Math.min(500, Math.max(1, maxFramesRaw)) : 100;
504
- const pattern = join(outDir, 'frame-%03d' + ext);
541
+ const pattern = join(runDir, 'frame-%03d' + ext);
505
542
  await runChecked(runner, extractArgs(cfg.ffmpegPath, { input, what: 'frames', output: pattern, overwrite: cfg.overwrite, fps: 1 / every, streamIndex: 0, maxFrames }), timeout, 'ffmpeg 抽帧', exec?.signal);
506
543
  }
507
- const files = readdirSync(outDir).filter((f) => f.startsWith('frame-') && f.endsWith(ext)).sort();
544
+ const files = readdirSync(runDir).filter((f) => f.startsWith('frame-') && f.endsWith(ext)).sort();
508
545
  const every = optionalNumber(args, 'every') ?? 1;
509
- return { outputDir: outDir, mode: times.length > 0 ? 'times' : 'every', every: times.length > 0 ? null : every, count: files.length, files: files.slice(0, 200) };
546
+ return { outputDir: runDir, mode: times.length > 0 ? 'times' : 'every', every: times.length > 0 ? null : every, count: files.length, files };
510
547
  },
511
548
  timeoutMs: timeout,
512
549
  };
@@ -536,8 +573,8 @@ export function buildFfmpegTools(config, runner) {
536
573
  if (speed !== undefined && (speed < 0.1 || speed > 100))
537
574
  throw new Error('speed 必须在 0.1-100 之间(当前:' + speed + ')。');
538
575
  const volume = optionalString(args, 'volume');
539
- if (volume !== undefined && !/^\d+(\.\d+)?$|^-?\d+(\.\d+)?dB$/.test(volume)) {
540
- throw new Error('volume 格式必须是倍数(如 1.5)或分贝(如 -3dB)。');
576
+ if (volume !== undefined && !/^[+-]?\d+(\.\d+)?(dB)?$/.test(volume)) {
577
+ throw new Error('volume 格式必须是倍数(如 1.5)或分贝(如 -3dB / +2dB)。');
541
578
  }
542
579
  const mute = args.mute === true;
543
580
  let rotate;
@@ -589,8 +626,10 @@ export function buildFfmpegTools(config, runner) {
589
626
  const checks = [];
590
627
  let ok = true;
591
628
  for (const [label, bin] of [['ffmpeg', cfg.ffmpegPath], ['ffprobe', cfg.ffprobePath]]) {
629
+ throwIfAborted(exec?.signal);
592
630
  try {
593
631
  const result = await runner.run([bin, '-version'], { timeoutMs: 15000, ...(exec?.signal === undefined ? {} : { signal: exec.signal }) });
632
+ throwIfAborted(exec?.signal);
594
633
  const firstLine = result.stdout.split(/\r?\n/)[0]?.trim() ?? '';
595
634
  if (result.exitCode === 0) {
596
635
  checks.push({ name: label, ok: true, path: bin, version: firstLine });
@@ -601,8 +640,7 @@ export function buildFfmpegTools(config, runner) {
601
640
  }
602
641
  }
603
642
  catch (error) {
604
- if (exec?.signal.aborted === true)
605
- throw error;
643
+ throwIfAborted(exec?.signal);
606
644
  ok = false;
607
645
  checks.push({ name: label, ok: false, path: bin, detail: error instanceof Error ? error.message : String(error) });
608
646
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-ffmpeg",
3
- "version": "0.4.2",
4
- "description": "DSH 视频处理工具插件:ffmpeg_probe/cut/concat/encode/subtitle/extract/gif/frames/adjust/health 十工具(探测摘要、批量抽帧、GIF、变速/音量/静音/旋转调整),进程走官方 subprocess 服务,超时树级终止、环境变量路径回退与多视频流探测,零运行时依赖(ffmpeg 本体除外)。",
3
+ "version": "0.4.3",
4
+ "description": "DSH 视频处理插件:ffmpeg 探测、裁剪、拼接、压制、字幕、GIF、抽帧与调整,走官方 subprocess 服务,零运行时依赖。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",