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 +2 -2
- package/README.md +3 -3
- package/lib/args.js +6 -1
- package/lib/config.js +20 -8
- package/lib/exec.d.ts +1 -0
- package/lib/exec.js +4 -1
- package/lib/index.js +1 -8
- package/lib/tools.d.ts +5 -1
- package/lib/tools.js +36 -31
- package/package.json +60 -60
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.
|
|
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 +
|
|
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
|
-
|
|
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 # 构建 +
|
|
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': {
|
|
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
|
-
|
|
17
|
-
|
|
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.
|
|
21
|
-
throw new Error('timeoutMs
|
|
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 =
|
|
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.
|
|
28
|
-
throw new Error('graceMs
|
|
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 =
|
|
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
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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.
|
|
4
|
-
"description": "DSH 视频处理工具插件:ffmpeg_probe/cut/concat/encode/subtitle/extract/gif
|
|
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
|
+
}
|