dsh-ffmpeg 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * dsh-ffmpeg —— 视频处理工具插件(node 半身,配置走 cordis.patch.yml)。
3
+ *
4
+ * 插件导出 apply(ctx, config):把七个面向模型的工具(ffmpeg_probe / ffmpeg_cut /
5
+ * ffmpeg_concat / ffmpeg_encode / ffmpeg_subtitle / ffmpeg_extract / ffmpeg_gif)注册进
6
+ * 宿主进程的工具注册表。进程执行走 DSH 官方 subprocess 服务(argv 数组、无 shell),
7
+ * 零运行时依赖。配置缺失时插件照常加载,工具在 execute 时才抛出带中文指引的错误。
8
+ *
9
+ * @module dsh-ffmpeg
10
+ */
11
+ import { type FfmpegConfig } from './config.js';
12
+ import { type SubprocessSpawnLike } from './exec.js';
13
+ import { type FfmpegToolDefinition } from './tools.js';
14
+ /** cordis 服务注入:apply 里要用 ctx.subprocess 与 ctx.tools,必须显式声明,否则宿主会抛 cannot get property without inject。 */
15
+ export declare const inject: string[];
16
+ /** 插件所需的最小 ctx 面(社区插件不依赖宿主内部类型)。 */
17
+ export interface FfmpegPluginContext {
18
+ subprocess: {
19
+ spawn: SubprocessSpawnLike;
20
+ };
21
+ tools: {
22
+ register(definition: FfmpegToolDefinition): () => void;
23
+ };
24
+ on?(event: string, listener: () => void): () => void;
25
+ }
26
+ /**
27
+ * 插件入口:解析配置、封装 subprocess 执行器并注册七个视频工具。
28
+ * @param ctx - 宿主上下文(至少含 subprocess.spawn 与 tools.register)。
29
+ * @param config - 插件配置(可缺省)。
30
+ */
31
+ export declare function apply(ctx: FfmpegPluginContext, config?: FfmpegConfig | null): void;
32
+ export * from './args.js';
33
+ export * from './config.js';
34
+ export * from './exec.js';
35
+ export * from './ffprobe.js';
36
+ export * from './paths.js';
37
+ export * from './tools.js';
package/lib/index.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * dsh-ffmpeg —— 视频处理工具插件(node 半身,配置走 cordis.patch.yml)。
3
+ *
4
+ * 插件导出 apply(ctx, config):把七个面向模型的工具(ffmpeg_probe / ffmpeg_cut /
5
+ * ffmpeg_concat / ffmpeg_encode / ffmpeg_subtitle / ffmpeg_extract / ffmpeg_gif)注册进
6
+ * 宿主进程的工具注册表。进程执行走 DSH 官方 subprocess 服务(argv 数组、无 shell),
7
+ * 零运行时依赖。配置缺失时插件照常加载,工具在 execute 时才抛出带中文指引的错误。
8
+ *
9
+ * @module dsh-ffmpeg
10
+ */
11
+ import { resolveConfig } from './config.js';
12
+ import { createSubprocessRunner } from './exec.js';
13
+ import { buildFfmpegTools } from './tools.js';
14
+ /** cordis 服务注入:apply 里要用 ctx.subprocess 与 ctx.tools,必须显式声明,否则宿主会抛 cannot get property without inject。 */
15
+ export const inject = ['subprocess', 'tools'];
16
+ /**
17
+ * 插件入口:解析配置、封装 subprocess 执行器并注册七个视频工具。
18
+ * @param ctx - 宿主上下文(至少含 subprocess.spawn 与 tools.register)。
19
+ * @param config - 插件配置(可缺省)。
20
+ */
21
+ export function apply(ctx, config) {
22
+ let cfg;
23
+ try {
24
+ cfg = resolveConfig(config);
25
+ }
26
+ catch (error) {
27
+ console.warn('[dsh-ffmpeg] ' + (error instanceof Error ? error.message : String(error)));
28
+ cfg = resolveConfig(null);
29
+ }
30
+ const runner = createSubprocessRunner(ctx.subprocess.spawn, cfg.graceMs, cfg.timeoutMs);
31
+ const disposers = [];
32
+ for (const definition of buildFfmpegTools(cfg, runner)) {
33
+ disposers.push(ctx.tools.register(definition));
34
+ }
35
+ if (typeof ctx.on === 'function') {
36
+ ctx.on('dispose', () => {
37
+ for (const dispose of disposers)
38
+ dispose();
39
+ });
40
+ }
41
+ }
42
+ export * from './args.js';
43
+ export * from './config.js';
44
+ export * from './exec.js';
45
+ export * from './ffprobe.js';
46
+ export * from './paths.js';
47
+ export * from './tools.js';
package/lib/paths.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /** 校验输入文件存在且是文件;返回绝对路径。 */
2
+ export declare function assertInputFile(input: string): string;
3
+ /** 清洗文件名中的危险字符。 */
4
+ export declare function sanitizeName(name: string): string;
5
+ /**
6
+ * 决定输出路径:缺省时放在输入同目录,名字 = 输入名 + suffix + ext。
7
+ * 目标已存在且不允许覆写时自动追加 _1/_2…。
8
+ */
9
+ export declare function resolveOutputPath(input: string, explicit: string | undefined, suffix: string, ext: string, overwrite: boolean): string;
package/lib/paths.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 路径与文件校验:输入存在性、输出命名(防覆写)、文件名清洗。
3
+ *
4
+ * @module dsh-ffmpeg/paths
5
+ */
6
+ import { existsSync } from 'node:fs';
7
+ import { basename, dirname, extname, join, resolve } from 'node:path';
8
+ /** 校验输入文件存在且是文件;返回绝对路径。 */
9
+ export function assertInputFile(input) {
10
+ const absolute = resolve(input);
11
+ if (!existsSync(absolute)) {
12
+ throw new Error('输入文件不存在:' + input);
13
+ }
14
+ return absolute;
15
+ }
16
+ /** 清洗文件名中的危险字符。 */
17
+ export function sanitizeName(name) {
18
+ const cleaned = name.trim().replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/\s+/g, '_').slice(0, 120);
19
+ return cleaned === '' ? 'output' : cleaned;
20
+ }
21
+ /**
22
+ * 决定输出路径:缺省时放在输入同目录,名字 = 输入名 + suffix + ext。
23
+ * 目标已存在且不允许覆写时自动追加 _1/_2…。
24
+ */
25
+ export function resolveOutputPath(input, explicit, suffix, ext, overwrite) {
26
+ let target;
27
+ if (explicit !== undefined && explicit.trim() !== '') {
28
+ target = resolve(explicit.trim());
29
+ }
30
+ else {
31
+ const base = basename(input, extname(input));
32
+ target = join(dirname(input), sanitizeName(base) + suffix + ext);
33
+ }
34
+ if (target.toLowerCase() === resolve(input).toLowerCase()) {
35
+ throw new Error('输出路径与输入文件相同,已拒绝(避免覆盖源文件)。');
36
+ }
37
+ if (overwrite || !existsSync(target))
38
+ return target;
39
+ const directory = dirname(target);
40
+ const base = basename(target, extname(target));
41
+ const extension = extname(target);
42
+ for (let index = 1; index < 1000; index++) {
43
+ const candidate = join(directory, base + '_' + index + extension);
44
+ if (!existsSync(candidate))
45
+ return candidate;
46
+ }
47
+ throw new Error('找不到可用的输出文件名(同名文件超过 999 个),请显式指定 output。');
48
+ }
package/lib/tools.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 七个面向模型的视频工具:probe / cut / concat / encode / subtitle / extract / gif。
3
+ * 直接调用 ctx.tools.register 注册【编译好的 JSON Schema】参数与 canonical 输出。
4
+ *
5
+ * @module dsh-ffmpeg/tools
6
+ */
7
+ import { type ResolvedFfmpegConfig } from './config.js';
8
+ import { type ProcessRunner } from './exec.js';
9
+ /** 模型可见的内容块。 */
10
+ export interface ContentBlock {
11
+ type: 'text';
12
+ text: string;
13
+ }
14
+ /** 注册给 ctx.tools.register 的原始工具定义。 */
15
+ export interface FfmpegToolDefinition {
16
+ name: string;
17
+ description: string;
18
+ parameters: {
19
+ type: 'object';
20
+ properties: Record<string, unknown>;
21
+ required?: string[];
22
+ };
23
+ output: {
24
+ schema: Record<string, unknown>;
25
+ render(args: unknown, value: unknown): ContentBlock[];
26
+ };
27
+ execute(args: unknown, exec: unknown): Promise<unknown>;
28
+ timeoutMs?: number;
29
+ }
30
+ /**
31
+ * 构建七个工具定义。
32
+ * @param config - 已解析配置。
33
+ * @param runner - 进程执行器(生产为 subprocess 服务封装,测试可注入假实现)。
34
+ */
35
+ export declare function buildFfmpegTools(config: ResolvedFfmpegConfig, runner: ProcessRunner): FfmpegToolDefinition[];
package/lib/tools.js ADDED
@@ -0,0 +1,412 @@
1
+ /**
2
+ * 七个面向模型的视频工具:probe / cut / concat / encode / subtitle / extract / gif。
3
+ * 直接调用 ctx.tools.register 注册【编译好的 JSON Schema】参数与 canonical 输出。
4
+ *
5
+ * @module dsh-ffmpeg/tools
6
+ */
7
+ import { rmSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { basename, dirname, extname, join } from 'node:path';
10
+ import { concatArgs, concatListContent, cutArgs, ENCODE_PRESETS, encodeArgs, extractArgs, fmtSeconds, gifPaletteArgs, gifUseArgs, probeArgs, subtitleArgs, } from './args.js';
11
+ import { parseProbeJson } from './ffprobe.js';
12
+ import { assertInputFile, resolveOutputPath, sanitizeName } from './paths.js';
13
+ import { parseTimeArg } from './config.js';
14
+ /** 编译作者 DSL 为原始 JSON Schema(正是 defineTool 存为 definition.parameters 的值)。 */
15
+ function compileParameters(spec) {
16
+ const properties = {};
17
+ const required = [];
18
+ for (const [key, prop] of Object.entries(spec)) {
19
+ if (prop?.required === true)
20
+ required.push(key);
21
+ const node = {};
22
+ if (typeof prop?.type === 'string')
23
+ node.type = prop.type;
24
+ if (typeof prop?.description === 'string')
25
+ node.description = prop.description;
26
+ if (prop?.type === 'array' && prop.items !== null && typeof prop.items === 'object') {
27
+ node.items = { type: 'string' };
28
+ }
29
+ properties[key] = node;
30
+ }
31
+ return { type: 'object', properties, ...(required.length > 0 ? { required } : {}) };
32
+ }
33
+ function asRecord(value) {
34
+ return typeof value === 'object' && value !== null ? value : {};
35
+ }
36
+ function optionalString(args, key) {
37
+ const value = args[key];
38
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
39
+ }
40
+ function requiredString(args, key, label) {
41
+ const value = optionalString(args, key);
42
+ if (value === undefined)
43
+ throw new Error(label + '(参数 ' + key + ')为必填,请提供非空字符串。');
44
+ return value;
45
+ }
46
+ function optionalNumber(args, key) {
47
+ const value = args[key];
48
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
49
+ }
50
+ function requiredTime(args, key, label) {
51
+ const value = parseTimeArg(args[key]);
52
+ if (value === null)
53
+ throw new Error(label + '(参数 ' + key + ')非法:请用秒数或 HH:MM:SS[.mmm] 格式。');
54
+ return value;
55
+ }
56
+ function optionalTime(args, key) {
57
+ const value = parseTimeArg(args[key]);
58
+ return value === null ? undefined : value;
59
+ }
60
+ function stringArray(args, key) {
61
+ const value = args[key];
62
+ if (!Array.isArray(value))
63
+ return [];
64
+ return value.filter((item) => typeof item === 'string' && item.trim() !== '').map((item) => item.trim());
65
+ }
66
+ /** 执行并检查退出码;非零抛中文错误(附 stderr 尾部)。 */
67
+ async function runChecked(runner, argv, timeoutMs, label) {
68
+ const result = await runner.run(argv, { timeoutMs });
69
+ if (result.exitCode !== 0) {
70
+ const tail = result.stderr.trim().split(/\r?\n/).slice(-6).join(' | ');
71
+ throw new Error(label + '失败(退出码 ' + String(result.exitCode ?? 'null') + (result.signal ? ',信号 ' + result.signal : '') + '):' + (tail || '无错误输出'));
72
+ }
73
+ return result;
74
+ }
75
+ function buildTextRenderer(lines) {
76
+ return (args, value) => [{ type: 'text', text: lines(args, value).join('\n') }];
77
+ }
78
+ // ---------- 输出 JSON Schema ----------
79
+ const baseSchema = { type: 'object', additionalProperties: true };
80
+ const videoStreamSchema = {
81
+ type: 'object',
82
+ properties: { width: { type: 'number' }, height: { type: 'number' }, fps: { type: 'number' }, codec: { type: 'string' }, durationSeconds: { type: 'number' }, bitrate: { type: 'number' } },
83
+ additionalProperties: true,
84
+ };
85
+ const audioStreamSchema = {
86
+ type: 'object',
87
+ properties: { codec: { type: 'string' }, sampleRate: { type: 'number' }, channels: { type: 'number' }, durationSeconds: { type: 'number' } },
88
+ additionalProperties: true,
89
+ };
90
+ const subtitleStreamSchema = {
91
+ type: 'object',
92
+ properties: { codec: { type: 'string' }, language: { type: 'string' } },
93
+ additionalProperties: true,
94
+ };
95
+ const probeSchema = {
96
+ type: 'object',
97
+ properties: {
98
+ ok: { type: 'boolean' },
99
+ input: { type: 'string' },
100
+ formatName: { type: 'string' },
101
+ durationSeconds: { type: 'number' },
102
+ sizeBytes: { type: 'number' },
103
+ bitrate: { type: 'number' },
104
+ video: videoStreamSchema,
105
+ audio: { type: 'array', items: audioStreamSchema },
106
+ subtitles: { type: 'array', items: subtitleStreamSchema },
107
+ },
108
+ additionalProperties: true,
109
+ };
110
+ const produceSchema = {
111
+ type: 'object',
112
+ properties: { output: { type: 'string' } },
113
+ additionalProperties: true,
114
+ };
115
+ // ---------- 工具构建 ----------
116
+ /**
117
+ * 构建七个工具定义。
118
+ * @param config - 已解析配置。
119
+ * @param runner - 进程执行器(生产为 subprocess 服务封装,测试可注入假实现)。
120
+ */
121
+ export function buildFfmpegTools(config, runner) {
122
+ const cfg = config;
123
+ const timeout = cfg.timeoutMs;
124
+ const probe = {
125
+ name: 'ffmpeg_probe',
126
+ description: '探测媒体文件信息:容器格式、时长、体积、码率,以及视频流(分辨率/帧率/编码)、音频流、字幕流。所有后续处理前建议先 probe。',
127
+ parameters: compileParameters({
128
+ input: { type: 'string', required: true, description: '媒体文件路径(必填)。' },
129
+ }),
130
+ output: {
131
+ schema: probeSchema,
132
+ render: buildTextRenderer((_args, value) => {
133
+ const rec = asRecord(value);
134
+ const video = asRecord(rec.video);
135
+ const lines = ['媒体信息(' + rec.input + '):'];
136
+ lines.push('- 容器:' + rec.formatName + ',时长:' + (rec.durationSeconds ?? '未知') + ' 秒,大小:' + (rec.sizeBytes ?? '未知') + ' 字节');
137
+ if (rec.video !== null && rec.video !== undefined)
138
+ lines.push('- 视频:' + video.codec + ' ' + video.width + 'x' + video.height + ',帧率:' + (video.fps ?? '未知'));
139
+ lines.push('- 音频流:' + (Array.isArray(rec.audio) ? rec.audio.length : 0) + ' 个,字幕流:' + (Array.isArray(rec.subtitles) ? rec.subtitles.length : 0) + ' 个');
140
+ return lines;
141
+ }),
142
+ },
143
+ async execute(rawArgs) {
144
+ const args = asRecord(rawArgs);
145
+ const input = assertInputFile(requiredString(args, 'input', '输入文件'));
146
+ const result = await runChecked(runner, probeArgs(cfg.ffprobePath, input), Math.min(timeout, 60000), 'ffprobe');
147
+ const media = parseProbeJson(result.stdout);
148
+ return { ok: true, input, ...media };
149
+ },
150
+ timeoutMs: Math.min(timeout, 60000),
151
+ };
152
+ const cut = {
153
+ name: 'ffmpeg_cut',
154
+ description: '剪辑视频片段。默认流拷贝(极快、关键帧对齐);reencode=true 时精确到帧重编码(慢)。start 为起始时间(秒或 HH:MM:SS.mmm,默认 0);end 与 duration 至少给一个(end 优先)。输出默认放在输入同目录,同名自动加序号。',
155
+ parameters: compileParameters({
156
+ input: { type: 'string', required: true, description: '输入文件(必填)。' },
157
+ start: { type: 'string', description: '起始时间(秒或 HH:MM:SS.mmm,默认 0)。' },
158
+ end: { type: 'string', description: '结束时间;与 duration 至少给一个。' },
159
+ duration: { type: 'string', description: '片段时长;与 end 至少给一个。' },
160
+ output: { type: 'string', description: '输出路径(可选,默认输入同目录加 .cut 后缀)。' },
161
+ reencode: { type: 'boolean', description: '是否精确重编码(默认 false=流拷贝)。' },
162
+ }),
163
+ output: {
164
+ schema: produceSchema,
165
+ render: buildTextRenderer((_args, value) => {
166
+ const rec = asRecord(value);
167
+ return ['剪辑完成:' + rec.output + '(' + fmtSeconds(Number(rec.duration ?? 0)) + ' 秒' + (rec.reencode === true ? ',已重编码' : ',流拷贝') + ')'];
168
+ }),
169
+ },
170
+ async execute(rawArgs) {
171
+ const args = asRecord(rawArgs);
172
+ const input = assertInputFile(requiredString(args, 'input', '输入文件'));
173
+ const start = optionalTime(args, 'start') ?? 0;
174
+ const end = optionalTime(args, 'end');
175
+ let duration;
176
+ if (end !== undefined) {
177
+ duration = end - start;
178
+ if (duration <= 0)
179
+ throw new Error('end 必须晚于 start。');
180
+ }
181
+ else {
182
+ duration = requiredTime(args, 'duration', '片段时长');
183
+ if (duration <= 0)
184
+ throw new Error('duration 必须大于 0。');
185
+ }
186
+ const reencode = args.reencode === true;
187
+ const output = resolveOutputPath(input, optionalString(args, 'output'), '.cut', extname(input) || '.mp4', cfg.overwrite);
188
+ await runChecked(runner, cutArgs(cfg.ffmpegPath, { input, start, duration, output, overwrite: cfg.overwrite, reencode }), timeout, 'ffmpeg 剪辑');
189
+ return { output, start, duration, reencode };
190
+ },
191
+ timeoutMs: timeout,
192
+ };
193
+ const concat = {
194
+ name: 'ffmpeg_concat',
195
+ description: '拼接多个视频片段。默认要求编码一致(流拷贝,秒级完成);reencode=true 时任意格式统一重编码拼接(慢)。inputs 为 2-20 个文件路径。',
196
+ parameters: compileParameters({
197
+ inputs: { type: 'array', items: { type: 'string' }, required: true, description: '输入文件路径数组(2-20 个,必填)。' },
198
+ output: { type: 'string', description: '输出路径(可选,默认第一个输入同目录加 .concat 后缀)。' },
199
+ reencode: { type: 'boolean', description: '是否统一重编码拼接(默认 false=流拷贝)。' },
200
+ }),
201
+ output: {
202
+ schema: produceSchema,
203
+ render: buildTextRenderer((_args, value) => {
204
+ const rec = asRecord(value);
205
+ return ['拼接完成:' + rec.output + '(' + rec.count + ' 个片段' + (rec.reencode === true ? ',已重编码' : ',流拷贝') + ')'];
206
+ }),
207
+ },
208
+ async execute(rawArgs) {
209
+ const args = asRecord(rawArgs);
210
+ const inputs = stringArray(args, 'inputs');
211
+ if (inputs.length < 2)
212
+ throw new Error('inputs 至少需要 2 个文件(当前 ' + inputs.length + ' 个)。');
213
+ if (inputs.length > 20)
214
+ throw new Error('inputs 最多 20 个文件(当前 ' + inputs.length + ' 个)。');
215
+ const absolute = inputs.map(assertInputFile);
216
+ const reencode = args.reencode === true;
217
+ const firstExt = extname(absolute[0]) || '.mp4';
218
+ const output = resolveOutputPath(absolute[0], optionalString(args, 'output'), '.concat', firstExt, cfg.overwrite);
219
+ if (!reencode) {
220
+ const listPath = join(tmpdir(), 'dsh-ffmpeg-concat-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8) + '.txt');
221
+ writeFileSync(listPath, concatListContent(absolute), 'utf8');
222
+ try {
223
+ await runChecked(runner, concatArgs(cfg.ffmpegPath, { inputs: absolute, listFilePath: listPath, output, overwrite: cfg.overwrite, reencode: false }), timeout, 'ffmpeg 拼接');
224
+ }
225
+ finally {
226
+ rmSync(listPath, { force: true });
227
+ }
228
+ }
229
+ else {
230
+ await runChecked(runner, concatArgs(cfg.ffmpegPath, { inputs: absolute, output, overwrite: cfg.overwrite, reencode: true }), timeout, 'ffmpeg 拼接');
231
+ }
232
+ return { output, count: absolute.length, reencode };
233
+ },
234
+ timeoutMs: timeout,
235
+ };
236
+ const encode = {
237
+ name: 'ffmpeg_encode',
238
+ description: '转码输出。预设:bilibili-1080p(H.264+AAC,码率上限 6000k,faststart,B 站推荐)、bilibili-4k(上限 20000k)、vertical-1080p(竖屏 1080x1920)、web-720p(轻量)。可选覆盖 crf(0-51)、fps、scale(如 1920:1080)。',
239
+ parameters: compileParameters({
240
+ input: { type: 'string', required: true, description: '输入文件(必填)。' },
241
+ preset: { type: 'string', description: '预设档位:bilibili-1080p / bilibili-4k / vertical-1080p / web-720p(默认 bilibili-1080p)。' },
242
+ crf: { type: 'integer', description: '质量系数 0-51,越小越清晰(可选,覆盖预设)。' },
243
+ fps: { type: 'number', description: '输出帧率(可选)。' },
244
+ scale: { type: 'string', description: '输出分辨率,如 1920:1080 或 -2:720(可选)。' },
245
+ output: { type: 'string', description: '输出路径(可选,默认输入同目录加 .encoded 后缀)。' },
246
+ }),
247
+ output: {
248
+ schema: produceSchema,
249
+ render: buildTextRenderer((_args, value) => {
250
+ const rec = asRecord(value);
251
+ return ['转码完成:' + rec.output + '(预设 ' + rec.preset + ',crf=' + rec.crf + ')'];
252
+ }),
253
+ },
254
+ async execute(rawArgs) {
255
+ const args = asRecord(rawArgs);
256
+ const input = assertInputFile(requiredString(args, 'input', '输入文件'));
257
+ const presetRaw = optionalString(args, 'preset') ?? 'bilibili-1080p';
258
+ if (!ENCODE_PRESETS.includes(presetRaw)) {
259
+ throw new Error('preset 必须是 ' + ENCODE_PRESETS.join(' / ') + ' 之一(当前:' + presetRaw + ')。');
260
+ }
261
+ const preset = presetRaw;
262
+ let crf;
263
+ const crfRaw = args.crf;
264
+ if (crfRaw !== undefined) {
265
+ if (typeof crfRaw !== 'number' || !Number.isInteger(crfRaw) || crfRaw < 0 || crfRaw > 51)
266
+ throw new Error('crf 必须是 0-51 的整数。');
267
+ crf = crfRaw;
268
+ }
269
+ let fps;
270
+ const fpsRaw = optionalNumber(args, 'fps');
271
+ if (fpsRaw !== undefined) {
272
+ if (fpsRaw <= 0 || fpsRaw > 240)
273
+ throw new Error('fps 必须是 0-240 之间的正数。');
274
+ fps = fpsRaw;
275
+ }
276
+ let scale;
277
+ const scaleRaw = optionalString(args, 'scale');
278
+ if (scaleRaw !== undefined) {
279
+ if (!/^-?\d+:-?\d+$/.test(scaleRaw))
280
+ throw new Error('scale 格式必须是 宽:高,如 1920:1080 或 -2:720。');
281
+ scale = scaleRaw;
282
+ }
283
+ const output = resolveOutputPath(input, optionalString(args, 'output'), '.encoded', extname(input) || '.mp4', cfg.overwrite);
284
+ await runChecked(runner, encodeArgs(cfg.ffmpegPath, { input, output, preset, crf, fps, scale, overwrite: cfg.overwrite }), timeout, 'ffmpeg 转码');
285
+ return { output, preset, crf: crf ?? 'preset', fps: fps ?? null, scale: scale ?? null };
286
+ },
287
+ timeoutMs: timeout,
288
+ };
289
+ const subtitle = {
290
+ name: 'ffmpeg_subtitle',
291
+ description: '把字幕文件(SRT/ASS 等)烧录进视频画面(硬字幕,任何播放器可见)。subtitle 为字幕文件路径。',
292
+ parameters: compileParameters({
293
+ input: { type: 'string', required: true, description: '输入视频(必填)。' },
294
+ subtitle: { type: 'string', required: true, description: '字幕文件路径(SRT/ASS,必填)。' },
295
+ output: { type: 'string', description: '输出路径(可选,默认输入同目录加 .sub 后缀)。' },
296
+ }),
297
+ output: {
298
+ schema: produceSchema,
299
+ render: buildTextRenderer((_args, value) => {
300
+ const rec = asRecord(value);
301
+ return ['字幕烧录完成:' + rec.output + '(硬字幕)'];
302
+ }),
303
+ },
304
+ async execute(rawArgs) {
305
+ const args = asRecord(rawArgs);
306
+ const input = assertInputFile(requiredString(args, 'input', '输入视频'));
307
+ const subtitlePath = assertInputFile(requiredString(args, 'subtitle', '字幕文件'));
308
+ const output = resolveOutputPath(input, optionalString(args, 'output'), '.sub', extname(input) || '.mp4', cfg.overwrite);
309
+ await runChecked(runner, subtitleArgs(cfg.ffmpegPath, { input, subtitle: subtitlePath, output, overwrite: cfg.overwrite }), timeout, 'ffmpeg 字幕');
310
+ return { output, mode: 'burn' };
311
+ },
312
+ timeoutMs: timeout,
313
+ };
314
+ const extract = {
315
+ name: 'ffmpeg_extract',
316
+ description: '提取媒体成分。what=audio 提取音轨(流拷贝 m4a);what=frames 按 fps 抽帧序列(输出为含 %03d 的 PNG 序列);what=frame 抽单帧(start 时刻,默认首帧);what=subtitle 提取字幕流(streamIndex 默认 0)。',
317
+ parameters: compileParameters({
318
+ input: { type: 'string', required: true, description: '输入文件(必填)。' },
319
+ what: { type: 'string', required: true, description: '提取内容:audio / frames / frame / subtitle(必填)。' },
320
+ output: { type: 'string', description: '输出路径(可选,frames 默认 输入名-%03d.png)。' },
321
+ start: { type: 'string', description: '起始时间(可选)。' },
322
+ duration: { type: 'string', description: '时长(frames 用,可选)。' },
323
+ fps: { type: 'number', description: '抽帧帧率(frames 用,默认 1)。' },
324
+ streamIndex: { type: 'integer', description: '字幕流序号(subtitle 用,默认 0)。' },
325
+ }),
326
+ output: {
327
+ schema: produceSchema,
328
+ render: buildTextRenderer((_args, value) => {
329
+ const rec = asRecord(value);
330
+ return ['提取完成(' + rec.what + '):' + rec.output];
331
+ }),
332
+ },
333
+ async execute(rawArgs) {
334
+ const args = asRecord(rawArgs);
335
+ const input = assertInputFile(requiredString(args, 'input', '输入文件'));
336
+ const what = requiredString(args, 'what', '提取内容');
337
+ const allowed = ['audio', 'frames', 'frame', 'subtitle'];
338
+ if (!allowed.includes(what))
339
+ throw new Error('what 必须是 ' + allowed.join(' / ') + ' 之一(当前:' + what + ')。');
340
+ const start = optionalTime(args, 'start');
341
+ const duration = optionalTime(args, 'duration');
342
+ const fps = optionalNumber(args, 'fps');
343
+ const streamIndex = typeof args.streamIndex === 'number' && Number.isInteger(args.streamIndex) && args.streamIndex >= 0 ? args.streamIndex : 0;
344
+ let output;
345
+ if (what === 'audio') {
346
+ output = resolveOutputPath(input, optionalString(args, 'output'), '.audio', '.m4a', cfg.overwrite);
347
+ }
348
+ else if (what === 'frame') {
349
+ output = resolveOutputPath(input, optionalString(args, 'output'), '.frame', '.png', cfg.overwrite);
350
+ }
351
+ else if (what === 'subtitle') {
352
+ output = resolveOutputPath(input, optionalString(args, 'output'), '.subtitle', '.srt', cfg.overwrite);
353
+ }
354
+ else {
355
+ const explicit = optionalString(args, 'output');
356
+ if (explicit !== undefined) {
357
+ output = explicit.includes('%') ? explicit : explicit.replace(/(\.[^.]+)$/, '-%03d$1');
358
+ }
359
+ else {
360
+ output = join(dirname(input), sanitizeName(basename(input, extname(input))) + '-%03d.png');
361
+ }
362
+ }
363
+ await runChecked(runner, extractArgs(cfg.ffmpegPath, { input, what: what, output, overwrite: cfg.overwrite, start, duration, fps, streamIndex }), timeout, 'ffmpeg 提取');
364
+ return { output, what, start: start ?? null, duration: duration ?? null, fps: fps ?? null, streamIndex };
365
+ },
366
+ timeoutMs: timeout,
367
+ };
368
+ const gif = {
369
+ name: 'ffmpeg_gif',
370
+ description: '视频转高质量 GIF(两遍调色板)。start 默认 0;duration 默认 10 秒;fps 默认 10(1-30);width 默认 480(64-1280)。',
371
+ parameters: compileParameters({
372
+ input: { type: 'string', required: true, description: '输入视频(必填)。' },
373
+ start: { type: 'string', description: '起始时间(默认 0)。' },
374
+ duration: { type: 'string', description: '时长(默认 10 秒)。' },
375
+ fps: { type: 'integer', description: '帧率 1-30(默认 10)。' },
376
+ width: { type: 'integer', description: '输出宽度 64-1280(默认 480)。' },
377
+ output: { type: 'string', description: '输出路径(可选,默认输入同目录加 .gif 后缀)。' },
378
+ }),
379
+ output: {
380
+ schema: produceSchema,
381
+ render: buildTextRenderer((_args, value) => {
382
+ const rec = asRecord(value);
383
+ return ['GIF 生成完成:' + rec.output + '(' + rec.width + 'px,' + rec.fps + 'fps,' + fmtSeconds(Number(rec.duration ?? 0)) + ' 秒)'];
384
+ }),
385
+ },
386
+ async execute(rawArgs) {
387
+ const args = asRecord(rawArgs);
388
+ const input = assertInputFile(requiredString(args, 'input', '输入视频'));
389
+ const start = optionalTime(args, 'start') ?? 0;
390
+ const duration = optionalTime(args, 'duration') ?? 10;
391
+ if (duration <= 0)
392
+ throw new Error('duration 必须大于 0。');
393
+ const fpsRaw = args.fps;
394
+ const fps = typeof fpsRaw === 'number' && Number.isInteger(fpsRaw) ? Math.min(30, Math.max(1, fpsRaw)) : 10;
395
+ const widthRaw = args.width;
396
+ const width = typeof widthRaw === 'number' && Number.isInteger(widthRaw) ? Math.min(1280, Math.max(64, widthRaw)) : 480;
397
+ const output = resolveOutputPath(input, optionalString(args, 'output'), '.gif', '.gif', cfg.overwrite);
398
+ const palettePath = output + '.palette.png';
399
+ const spec = { input, output, palettePath, overwrite: cfg.overwrite, start, duration, fps, width };
400
+ try {
401
+ await runChecked(runner, gifPaletteArgs(cfg.ffmpegPath, spec), timeout, 'ffmpeg GIF 调色板');
402
+ await runChecked(runner, gifUseArgs(cfg.ffmpegPath, spec), timeout, 'ffmpeg GIF 合成');
403
+ }
404
+ finally {
405
+ rmSync(palettePath, { force: true });
406
+ }
407
+ return { output, start, duration, fps, width };
408
+ },
409
+ timeoutMs: timeout,
410
+ };
411
+ return [probe, cut, concat, encode, subtitle, extract, gif];
412
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "dsh-ffmpeg",
3
+ "version": "0.1.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
+ "prepare": "tsc -p tsconfig.json",
25
+ "typecheck": "tsc -p tsconfig.json --noEmit",
26
+ "test": "pnpm run build && node --test \"test/*.test.mjs\"",
27
+ "prepublishOnly": "pnpm run build"
28
+ },
29
+ "dsh": {
30
+ "bundle": {
31
+ "patch": "./cordis.patch.yml"
32
+ }
33
+ },
34
+ "keywords": [
35
+ "dsh",
36
+ "deepseek-harness",
37
+ "plugin",
38
+ "ffmpeg",
39
+ "video",
40
+ "media"
41
+ ],
42
+ "license": "MIT",
43
+ "engines": {
44
+ "node": ">=20"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^24.0.0",
48
+ "typescript": "^5.6.0"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "https://github.com/STARDUSTLC666/dsh-ffmpeg"
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
+ }