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 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
  [![npm](https://img.shields.io/npm/v/dsh-ffmpeg)](https://www.npmjs.com/package/dsh-ffmpeg) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-ffmpeg-downloads.svg)](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
  [![npm](https://img.shields.io/npm/v/dsh-ffmpeg)](https://www.npmjs.com/package/dsh-ffmpeg) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-ffmpeg-downloads.svg)](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` | Concatenate 2-20 clips (stream copy for identical codecs / re-encode for mixed; video-only when any input has no audio) | `inputs` array required |
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 + 104 tests
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 个片段(同编码流拷贝 / 混合编码重编码;无音轨输入按纯视频拼接) | `inputs` 数组必填 |
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 # 构建 + 104 个测试
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
- 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)
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', '-c:a', 'aac', spec.output);
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', '-i', spec.input, '-ss', fmtSeconds(spec.start), '-t', fmtSeconds(spec.duration), '-vf', filter, spec.palettePath];
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), '-i', spec.input, '-ss', fmtSeconds(spec.start), '-t', fmtSeconds(spec.duration), '-i', spec.palettePath, '-filter_complex', filter, spec.output];
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: '拼接多个视频片段。默认要求编码一致(流拷贝,秒级完成);reencode=true 时统一重编码拼接(慢;先 probe 各输入,任一输入无音轨就按纯视频拼接,避免音轨绑定失败)。inputs 为 2-20 个文件路径。',
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
- // concat filter 的 a=1 要求每个输入都含音频轨,否则报 Error binding filtergraph inputs/outputs;先 probe 确认
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
- if (parseProbeJson(probeResult.stdout).audio.length === 0) {
286
- allHaveAudio = false;
287
- break;
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
- await runChecked(runner, concatArgs(cfg.ffmpegPath, { inputs: absolute, output, overwrite: cfg.overwrite, reencode: true, hasAudio: allHaveAudio }), timeout, 'ffmpeg 拼接', exec?.signal);
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ffmpeg",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
4
4
  "description": "用自然语言调用 FFmpeg,处理已有音视频文件。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",