dsh-ffmpeg 0.4.5 → 0.5.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/CHANGELOG.md +6 -0
- package/README.en.md +3 -0
- package/README.md +3 -0
- package/docs/USAGE.en.md +2 -2
- package/docs/USAGE.md +2 -2
- package/lib/args.d.ts +11 -0
- package/lib/args.js +30 -6
- package/lib/tools.js +35 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
[历史英文记录](docs/CHANGELOG.en.md)
|
|
6
6
|
|
|
7
|
+
## 0.5.0 (2026-10-15)
|
|
8
|
+
|
|
9
|
+
- 修复 GIF 的起点和时长选项作用到错误输入,调色板以单帧写入;非零起点也能按指定区间导出。
|
|
10
|
+
- 重编码拼接先统一画布、帧率、像素比例与时间戳,可指定目标宽高和帧率,等比补边。
|
|
11
|
+
- 混合有声/无声素材时只为无声片段补静音,保留其余片段的声音;全无声素材保持无音轨。
|
|
12
|
+
|
|
7
13
|
## 0.4.5 (2026-10-05)
|
|
8
14
|
|
|
9
15
|
- 输入、输出、字幕与抽帧路径按当前 DSH 会话工作区解析,避免宿主启动目录造成找错文件;非法时间参数立即报错。
|
package/README.en.md
CHANGED
|
@@ -8,9 +8,12 @@ Use FFmpeg to process existing audio and video files through natural-language re
|
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/dsh-ffmpeg) [](https://www.npmjs.com/package/dsh-ffmpeg)
|
|
10
10
|
|
|
11
|
+
Feedback and contributions are welcome: report [issues](https://github.com/STARDUSTLC666/dsh-ffmpeg/issues) or submit [pull requests](https://github.com/STARDUSTLC666/dsh-ffmpeg/pulls).
|
|
12
|
+
|
|
11
13
|
## What it does
|
|
12
14
|
|
|
13
15
|
- Inspect media and trim, join or transcode video.
|
|
16
|
+
- Fit clips with different sizes and frame rates to one canvas; silent clips receive silence without muting the other clips.
|
|
14
17
|
- Add subtitles, create GIFs and extract frames.
|
|
15
18
|
- Execute commands through the host subprocess service.
|
|
16
19
|
|
package/README.md
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/dsh-ffmpeg) [](https://www.npmjs.com/package/dsh-ffmpeg)
|
|
10
10
|
|
|
11
|
+
欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-ffmpeg/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-ffmpeg/pulls)。
|
|
12
|
+
|
|
11
13
|
## 功能
|
|
12
14
|
|
|
13
15
|
- 探测媒体信息,裁剪、拼接和压制视频。
|
|
16
|
+
- 拼接不同尺寸和帧率的素材时可统一画布并等比补边;无声片段补静音,保留其余原声。
|
|
14
17
|
- 添加字幕、生成 GIF、抽取画面。
|
|
15
18
|
- 通过宿主 subprocess 执行命令。
|
|
16
19
|
|
package/docs/USAGE.en.md
CHANGED
|
@@ -42,7 +42,7 @@ Override the plugin row in your profile's `cordis.patch.yml` (defaults apply whe
|
|
|
42
42
|
| :-- | :-- | :-- |
|
|
43
43
|
| `ffmpeg_probe` | Probe media info (format/duration/resolution/fps/bitrate/audio/subtitle streams; multi-video files return a full videos list) | `input` required |
|
|
44
44
|
| `ffmpeg_cut` | Cut a clip (stream copy by default, accurate re-encode optional) | `input` required; `start`/`end`/`duration` |
|
|
45
|
-
| `ffmpeg_concat` |
|
|
45
|
+
| `ffmpeg_concat` | Join 2-20 clips; re-encoding normalizes size/fps/SAR with letterboxing and fills silent clips while keeping other audio | `inputs` required; with `reencode=true`, optionally set `width` + `height` and `fps` |
|
|
46
46
|
| `ffmpeg_encode` | Transcode with presets (bilibili 1080p/4K, vertical 1080p, web-720p) plus crf/fps/scale overrides | `input` required; `preset` optional |
|
|
47
47
|
| `ffmpeg_subtitle` | Burn subtitles (SRT/ASS hard subs) | `input` + `subtitle` required |
|
|
48
48
|
| `ffmpeg_extract` | Extract audio (m4a; non-AAC is auto-transcoded to AAC) / frame sequences / single frame / subtitle stream | `input` + `what` required |
|
|
@@ -71,7 +71,7 @@ ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
|
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
73
|
pnpm install
|
|
74
|
-
pnpm test # build +
|
|
74
|
+
pnpm test # build + 110 tests
|
|
75
75
|
```
|
|
76
76
|
|
|
77
77
|
## License
|
package/docs/USAGE.md
CHANGED
|
@@ -42,7 +42,7 @@ dsh plugin --profile web remove dsh-ffmpeg
|
|
|
42
42
|
| :-- | :-- | :-- |
|
|
43
43
|
| `ffmpeg_probe` | 探测媒体信息(格式/时长/分辨率/帧率/码率/音轨/字幕轨;多视频流时返回完整 videos 列表) | `input` 必填 |
|
|
44
44
|
| `ffmpeg_cut` | 剪辑片段(默认流拷贝秒级,可精确重编码) | `input` 必填;`start`/`end`/`duration` |
|
|
45
|
-
| `ffmpeg_concat` | 拼接 2-20
|
|
45
|
+
| `ffmpeg_concat` | 拼接 2-20 个片段;重编码时等比补边到同一画布,混合无声片段会补静音并保留其余原声 | `inputs` 数组必填;`reencode=true` 时可用 `width` + `height`、`fps` 指定输出 |
|
|
46
46
|
| `ffmpeg_encode` | 转码(B 站 1080p/4K、竖屏 1080p、web-720p 预设 + crf/fps/scale 覆盖) | `input` 必填;`preset` 可选 |
|
|
47
47
|
| `ffmpeg_subtitle` | 字幕烧录(SRT/ASS 硬字幕) | `input`+`subtitle` 必填 |
|
|
48
48
|
| `ffmpeg_extract` | 提取音轨(m4a,非 AAC 自动转 AAC)/ 抽帧序列 / 单帧 / 字幕流 | `input`+`what` 必填 |
|
|
@@ -75,7 +75,7 @@ ffmpeg_adjust { input: E:\videos\raw.mp4, volume: +2dB }
|
|
|
75
75
|
|
|
76
76
|
```bash
|
|
77
77
|
pnpm install
|
|
78
|
-
pnpm test # 构建 +
|
|
78
|
+
pnpm test # 构建 + 110 个测试
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
## License
|
package/lib/args.d.ts
CHANGED
|
@@ -28,6 +28,17 @@ export interface ConcatSpec {
|
|
|
28
28
|
reencode: boolean;
|
|
29
29
|
/** 所有输入都含音频流(probe 结果):只有全有才能用 a=1,否则 concat filter 绑定失败。 */
|
|
30
30
|
hasAudio?: boolean;
|
|
31
|
+
/** 统一到目标画布;等比缩放并补边,不拉伸画面。 */
|
|
32
|
+
normalize?: {
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
fps: number;
|
|
36
|
+
};
|
|
37
|
+
/** 缺音轨片段用等长静音补齐,保留其余片段的原声。 */
|
|
38
|
+
audioInputs?: Array<{
|
|
39
|
+
present: boolean;
|
|
40
|
+
duration: number;
|
|
41
|
+
}>;
|
|
31
42
|
}
|
|
32
43
|
/** 拼接:同编码流拷贝走 concat demuxer(需 list 文件),否则 filter_complex 重编码。 */
|
|
33
44
|
export declare function concatArgs(ffmpeg: string, spec: ConcatSpec): string[];
|
package/lib/args.js
CHANGED
|
@@ -37,11 +37,35 @@ 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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
const hasAudio = spec.audioInputs?.some(input => input.present) ?? spec.hasAudio !== false;
|
|
41
|
+
const filters = [];
|
|
42
|
+
const pads = [];
|
|
43
|
+
spec.inputs.forEach((_input, i) => {
|
|
44
|
+
const steps = [];
|
|
45
|
+
if (spec.normalize !== undefined) {
|
|
46
|
+
const { width, height, fps } = spec.normalize;
|
|
47
|
+
// First convert anamorphic pixels to square pixels, then fit the shared canvas.
|
|
48
|
+
steps.push('scale=trunc(iw*sar/2)*2:ih', 'setsar=1', 'scale=' + width + ':' + height + ':force_original_aspect_ratio=decrease:force_divisible_by=2', 'pad=' + width + ':' + height + ':(ow-iw)/2:(oh-ih)/2', 'fps=' + fps);
|
|
49
|
+
}
|
|
50
|
+
steps.push('setsar=1', 'format=yuv420p', 'setpts=PTS-STARTPTS');
|
|
51
|
+
filters.push('[' + i + ':v:0]' + steps.join(',') + '[v' + i + ']');
|
|
52
|
+
pads.push('[v' + i + ']');
|
|
53
|
+
if (hasAudio) {
|
|
54
|
+
const audio = spec.audioInputs?.[i];
|
|
55
|
+
const source = audio?.present === false
|
|
56
|
+
? 'anullsrc=r=48000:cl=stereo,atrim=duration=' + fmtSeconds(audio.duration)
|
|
57
|
+
: '[' + i + ':a:0]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo';
|
|
58
|
+
filters.push(source + ',asetpts=PTS-STARTPTS[a' + i + ']');
|
|
59
|
+
pads.push('[a' + i + ']');
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
filters.push(pads.join('') + 'concat=n=' + spec.inputs.length + ':v=1:a=' + (hasAudio ? '1[vout][aout]' : '0[vout]'));
|
|
63
|
+
parts.push('-filter_complex', filters.join(';'), '-map', '[vout]');
|
|
64
|
+
if (hasAudio)
|
|
65
|
+
parts.push('-map', '[aout]', '-c:a', 'aac');
|
|
66
|
+
else
|
|
43
67
|
parts.push('-an');
|
|
44
|
-
parts.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18',
|
|
68
|
+
parts.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', spec.output);
|
|
45
69
|
return parts;
|
|
46
70
|
}
|
|
47
71
|
/** concat demuxer 的 list 文件内容(路径中的单引号按 ffmpeg 规则转义)。 */
|
|
@@ -116,12 +140,12 @@ export function frameAtArgs(ffmpeg, spec) {
|
|
|
116
140
|
/** GIF 第一遍:调色板生成(palettegen)。 */
|
|
117
141
|
export function gifPaletteArgs(ffmpeg, spec) {
|
|
118
142
|
const filter = 'fps=' + spec.fps + ',scale=' + spec.width + ':-1:flags=lanczos,palettegen';
|
|
119
|
-
return [ffmpeg, '-y', '-
|
|
143
|
+
return [ffmpeg, '-y', '-ss', fmtSeconds(spec.start), '-t', fmtSeconds(spec.duration), '-i', spec.input, '-vf', filter, '-frames:v', '1', '-update', '1', spec.palettePath];
|
|
120
144
|
}
|
|
121
145
|
/** GIF 第二遍:paletteuse 合成。 */
|
|
122
146
|
export function gifUseArgs(ffmpeg, spec) {
|
|
123
147
|
const filter = 'fps=' + spec.fps + ',scale=' + spec.width + ':-1:flags=lanczos[x];[x][1:v]paletteuse';
|
|
124
|
-
return [ffmpeg, overwriteFlag(spec.overwrite), '-
|
|
148
|
+
return [ffmpeg, overwriteFlag(spec.overwrite), '-ss', fmtSeconds(spec.start), '-t', fmtSeconds(spec.duration), '-i', spec.input, '-i', spec.palettePath, '-filter_complex', filter, spec.output];
|
|
125
149
|
}
|
|
126
150
|
/**
|
|
127
151
|
* atempo 单滤镜只接受约 [0.5, 100],超出范围用多个 atempo 级联
|
package/lib/tools.js
CHANGED
|
@@ -243,11 +243,14 @@ export function buildFfmpegTools(config, runner) {
|
|
|
243
243
|
};
|
|
244
244
|
const concat = {
|
|
245
245
|
name: 'ffmpeg_concat',
|
|
246
|
-
description: '
|
|
246
|
+
description: '拼接 2-20 个视频片段。默认同编码流拷贝;reencode=true 将不同分辨率、帧率和像素比例统一到第一个片段的画布,等比补边;可指定 width/height/fps。混合有声与无声素材时只为无声段补静音,保留其他段的声音。',
|
|
247
247
|
parameters: compileParameters({
|
|
248
248
|
inputs: { type: 'array', items: { type: 'string' }, required: true, description: '输入文件路径数组(2-20 个,必填)。' },
|
|
249
249
|
output: { type: 'string', description: '输出路径(可选,默认第一个输入同目录加 .concat 后缀)。' },
|
|
250
250
|
reencode: { type: 'boolean', description: '是否统一重编码拼接(默认 false=流拷贝)。' },
|
|
251
|
+
width: { type: 'integer', description: '重编码目标宽度;与 height 一起指定,2-8192 的偶数。默认采用首段宽度。' },
|
|
252
|
+
height: { type: 'integer', description: '重编码目标高度;与 width 一起指定,2-8192 的偶数。默认采用首段高度。' },
|
|
253
|
+
fps: { type: 'number', description: '重编码目标帧率 1-240。默认采用首段帧率,无法识别时用 30。' },
|
|
251
254
|
}),
|
|
252
255
|
output: {
|
|
253
256
|
schema: produceSchema,
|
|
@@ -265,8 +268,22 @@ export function buildFfmpegTools(config, runner) {
|
|
|
265
268
|
throw new Error('inputs 最多 20 个文件(当前 ' + inputs.length + ' 个)。');
|
|
266
269
|
const absolute = inputs.map(input => assertInputFile(input, executionCwd(exec)));
|
|
267
270
|
const reencode = args.reencode === true;
|
|
271
|
+
const requestedWidth = optionalNumber(args, 'width');
|
|
272
|
+
const requestedHeight = optionalNumber(args, 'height');
|
|
273
|
+
const requestedFps = optionalNumber(args, 'fps');
|
|
274
|
+
if ([requestedWidth, requestedHeight, requestedFps].some(value => value !== undefined) && !reencode)
|
|
275
|
+
throw new Error('width/height/fps 需要 reencode=true。');
|
|
276
|
+
if ((requestedWidth === undefined) !== (requestedHeight === undefined))
|
|
277
|
+
throw new Error('width 与 height 需要一起指定。');
|
|
278
|
+
for (const [name, value] of [['width', requestedWidth], ['height', requestedHeight]]) {
|
|
279
|
+
if (value !== undefined && (!Number.isInteger(value) || value < 2 || value > 8192 || value % 2 !== 0))
|
|
280
|
+
throw new Error(name + ' 必须是 2-8192 的偶数。');
|
|
281
|
+
}
|
|
282
|
+
if (requestedFps !== undefined && (!Number.isFinite(requestedFps) || requestedFps < 1 || requestedFps > 240))
|
|
283
|
+
throw new Error('fps 必须在 1-240 之间。');
|
|
268
284
|
const firstExt = extname(absolute[0]) || '.mp4';
|
|
269
285
|
const output = resolveOutputPath(absolute[0], optionalString(args, 'output'), '.concat', firstExt, cfg.overwrite, executionCwd(exec));
|
|
286
|
+
let normalized;
|
|
270
287
|
if (!reencode) {
|
|
271
288
|
const listPath = join(tmpdir(), 'dsh-ffmpeg-concat-' + Date.now() + '-' + randomUUID().slice(0, 8) + '.txt');
|
|
272
289
|
writeFileSync(listPath, concatListContent(absolute), 'utf8');
|
|
@@ -278,18 +295,27 @@ export function buildFfmpegTools(config, runner) {
|
|
|
278
295
|
}
|
|
279
296
|
}
|
|
280
297
|
else {
|
|
281
|
-
|
|
282
|
-
let allHaveAudio = true;
|
|
298
|
+
const media = [];
|
|
283
299
|
for (const inputPath of absolute) {
|
|
284
300
|
const probeResult = await runChecked(runner, probeArgs(cfg.ffprobePath, inputPath), Math.min(timeout, 60000), 'ffprobe', exec?.signal);
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
301
|
+
const info = parseProbeJson(probeResult.stdout);
|
|
302
|
+
if (info.video === null || info.video.width < 1 || info.video.height < 1)
|
|
303
|
+
throw new Error('拼接输入缺少有效视频画面:' + inputPath);
|
|
304
|
+
media.push(info);
|
|
289
305
|
}
|
|
290
|
-
|
|
306
|
+
const first = media[0].video;
|
|
307
|
+
normalized = { width: requestedWidth ?? Math.ceil(first.width / 2) * 2, height: requestedHeight ?? Math.ceil(first.height / 2) * 2,
|
|
308
|
+
fps: requestedFps ?? (first.fps !== null && first.fps >= 1 && first.fps <= 240 ? first.fps : 30) };
|
|
309
|
+
const anyAudio = media.some(info => info.audio.length > 0);
|
|
310
|
+
const audioInputs = media.map((info, index) => {
|
|
311
|
+
const duration = info.video.durationSeconds ?? info.durationSeconds ?? 0;
|
|
312
|
+
if (anyAudio && info.audio.length === 0 && duration <= 0)
|
|
313
|
+
throw new Error('无法确定无声片段的时长,不能安全补齐静音:' + absolute[index]);
|
|
314
|
+
return { present: info.audio.length > 0, duration };
|
|
315
|
+
});
|
|
316
|
+
await runChecked(runner, concatArgs(cfg.ffmpegPath, { inputs: absolute, output, overwrite: cfg.overwrite, reencode: true, normalize: normalized, audioInputs }), timeout, 'ffmpeg 拼接', exec?.signal);
|
|
291
317
|
}
|
|
292
|
-
return { output, count: absolute.length, reencode };
|
|
318
|
+
return { output, count: absolute.length, reencode, ...(normalized === undefined ? {} : { normalized }) };
|
|
293
319
|
},
|
|
294
320
|
timeoutMs: timeout,
|
|
295
321
|
};
|