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 +8 -3
- package/README.md +9 -3
- package/lib/args.d.ts +4 -0
- package/lib/args.js +8 -1
- package/lib/exec.d.ts +4 -0
- package/lib/exec.js +12 -3
- package/lib/tools.js +52 -14
- package/package.json +2 -2
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
|
+
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 个片段(同编码流拷贝 /
|
|
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 # 构建 +
|
|
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=
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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 &&
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "DSH
|
|
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",
|