dsh-ffmpeg 0.4.5 → 0.5.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/CHANGELOG.md +11 -0
- package/README.en.md +3 -0
- package/README.md +3 -0
- package/docs/USAGE.en.md +6 -2
- package/docs/USAGE.md +6 -2
- package/docs/VALIDATION.md +4 -0
- package/docs/validation-0.5.1.md +19 -0
- package/lib/args.d.ts +11 -0
- package/lib/args.js +30 -6
- package/lib/paths.d.ts +5 -1
- package/lib/paths.js +80 -6
- package/lib/tools.js +39 -26
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
[历史英文记录](docs/CHANGELOG.en.md)
|
|
6
6
|
|
|
7
|
+
## 0.5.1(2026-10-26)
|
|
8
|
+
|
|
9
|
+
- 抽帧默认使用每次独立的 run 目录;显式帧序列检查实际文件冲突。输出保护覆盖所有拼接输入、字幕源、符号链接及硬链接别名。
|
|
10
|
+
- Windows 回归 114 项通过、1 项跳过;宿主与未验收范围见[本次记录](docs/validation-0.5.1.md)。
|
|
11
|
+
|
|
12
|
+
## 0.5.0 (2026-10-15)
|
|
13
|
+
|
|
14
|
+
- 修复 GIF 的起点和时长选项作用到错误输入,调色板以单帧写入;非零起点也能按指定区间导出。
|
|
15
|
+
- 重编码拼接先统一画布、帧率、像素比例与时间戳,可指定目标宽高和帧率,等比补边。
|
|
16
|
+
- 混合有声/无声素材时只为无声片段补静音,保留其余片段的声音;全无声素材保持无音轨。
|
|
17
|
+
|
|
7
18
|
## 0.4.5 (2026-10-05)
|
|
8
19
|
|
|
9
20
|
- 输入、输出、字幕与抽帧路径按当前 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
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
[Overview](../README.en.md) · [Changelog](../CHANGELOG.md) · [Validation](VALIDATION.md)
|
|
4
4
|
|
|
5
|
+
## Frame output and source protection
|
|
6
|
+
|
|
7
|
+
Default frame sequences go to a fresh `<input>-frames/run-*/` directory. An explicit pattern needs `overwrite=true` for existing outputs; even then it cannot overwrite any input/subtitle source or its file aliases.
|
|
8
|
+
|
|
5
9
|
## Current improvements
|
|
6
10
|
|
|
7
11
|
Relative paths resolve against the current session workspace. Explicit start / duration values must be valid; invalid values never silently fall back to full-video processing.
|
|
@@ -42,7 +46,7 @@ Override the plugin row in your profile's `cordis.patch.yml` (defaults apply whe
|
|
|
42
46
|
| :-- | :-- | :-- |
|
|
43
47
|
| `ffmpeg_probe` | Probe media info (format/duration/resolution/fps/bitrate/audio/subtitle streams; multi-video files return a full videos list) | `input` required |
|
|
44
48
|
| `ffmpeg_cut` | Cut a clip (stream copy by default, accurate re-encode optional) | `input` required; `start`/`end`/`duration` |
|
|
45
|
-
| `ffmpeg_concat` |
|
|
49
|
+
| `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
50
|
| `ffmpeg_encode` | Transcode with presets (bilibili 1080p/4K, vertical 1080p, web-720p) plus crf/fps/scale overrides | `input` required; `preset` optional |
|
|
47
51
|
| `ffmpeg_subtitle` | Burn subtitles (SRT/ASS hard subs) | `input` + `subtitle` required |
|
|
48
52
|
| `ffmpeg_extract` | Extract audio (m4a; non-AAC is auto-transcoded to AAC) / frame sequences / single frame / subtitle stream | `input` + `what` required |
|
|
@@ -71,7 +75,7 @@ ffmpeg_gif { input: E:\videos\raw.mp4, duration: 3, width: 480 }
|
|
|
71
75
|
|
|
72
76
|
```bash
|
|
73
77
|
pnpm install
|
|
74
|
-
pnpm test # build +
|
|
78
|
+
pnpm test # build + 115 tests
|
|
75
79
|
```
|
|
76
80
|
|
|
77
81
|
## License
|
package/docs/USAGE.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
[返回简介](../README.md) · [更新记录](../CHANGELOG.md) · [验证记录](VALIDATION.md)
|
|
4
4
|
|
|
5
|
+
## 抽帧与源文件保护
|
|
6
|
+
|
|
7
|
+
默认帧序列写入 `<输入名>-frames/run-*/`,每次使用新目录。显式指定序列时,已有输出需要 `overwrite=true`;即使允许覆盖,也不能覆盖任何输入或字幕源及其文件别名。
|
|
8
|
+
|
|
5
9
|
## 本次改进
|
|
6
10
|
|
|
7
11
|
相对文件路径以当前会话工作区为基准。提供 start / duration 时必须是合法时间,非法输入不会退回默认值或意外处理整段视频。
|
|
@@ -42,7 +46,7 @@ dsh plugin --profile web remove dsh-ffmpeg
|
|
|
42
46
|
| :-- | :-- | :-- |
|
|
43
47
|
| `ffmpeg_probe` | 探测媒体信息(格式/时长/分辨率/帧率/码率/音轨/字幕轨;多视频流时返回完整 videos 列表) | `input` 必填 |
|
|
44
48
|
| `ffmpeg_cut` | 剪辑片段(默认流拷贝秒级,可精确重编码) | `input` 必填;`start`/`end`/`duration` |
|
|
45
|
-
| `ffmpeg_concat` | 拼接 2-20
|
|
49
|
+
| `ffmpeg_concat` | 拼接 2-20 个片段;重编码时等比补边到同一画布,混合无声片段会补静音并保留其余原声 | `inputs` 数组必填;`reencode=true` 时可用 `width` + `height`、`fps` 指定输出 |
|
|
46
50
|
| `ffmpeg_encode` | 转码(B 站 1080p/4K、竖屏 1080p、web-720p 预设 + crf/fps/scale 覆盖) | `input` 必填;`preset` 可选 |
|
|
47
51
|
| `ffmpeg_subtitle` | 字幕烧录(SRT/ASS 硬字幕) | `input`+`subtitle` 必填 |
|
|
48
52
|
| `ffmpeg_extract` | 提取音轨(m4a,非 AAC 自动转 AAC)/ 抽帧序列 / 单帧 / 字幕流 | `input`+`what` 必填 |
|
|
@@ -75,7 +79,7 @@ ffmpeg_adjust { input: E:\videos\raw.mp4, volume: +2dB }
|
|
|
75
79
|
|
|
76
80
|
```bash
|
|
77
81
|
pnpm install
|
|
78
|
-
pnpm test # 构建 +
|
|
82
|
+
pnpm test # 构建 + 115 个测试
|
|
79
83
|
```
|
|
80
84
|
|
|
81
85
|
## License
|
package/docs/VALIDATION.md
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# 0.5.1 验证记录
|
|
2
|
+
|
|
3
|
+
日期:2026-10-26。官方 Windows 宿主:DSH 0.2.1-rc.3。
|
|
4
|
+
|
|
5
|
+
## 行为变化
|
|
6
|
+
|
|
7
|
+
抽帧默认使用每次独立的 run 目录;显式帧序列检查实际文件冲突。输出保护覆盖所有拼接输入、字幕源、符号链接及硬链接别名。
|
|
8
|
+
|
|
9
|
+
## 验证与限制
|
|
10
|
+
|
|
11
|
+
- Windows 本地:115 项,114 通过、1 跳过;没有把跳过计为通过。
|
|
12
|
+
- 官方 RC.3 调用真实 FFmpeg 连续抽帧两次,目录互不覆盖,原有帧哨兵保持不变。Windows 跳过 1 项 POSIX 大小写检查,Linux 结果以 CI 为准。
|
|
13
|
+
- GitHub 的跨平台 CI 和 npm 发布完整性以该版本工作流及 registry 为准;本记录不把本地测试等同于已发布。
|
|
14
|
+
|
|
15
|
+
## English
|
|
16
|
+
|
|
17
|
+
Frame extraction uses a separate run directory by default. Explicit frame sequences check existing output names. Output protection covers every concat input, subtitle sources and symlink/hard-link aliases.
|
|
18
|
+
|
|
19
|
+
Runtime/account limits are listed above. Synthetic peers and model adapters do not establish live account delivery or model quality.
|
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/paths.d.ts
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
export declare function assertInputFile(input: string, cwd?: string): string;
|
|
3
3
|
/** 清洗文件名中的危险字符。 */
|
|
4
4
|
export declare function sanitizeName(name: string): string;
|
|
5
|
+
/** Compare every source, resolving junctions/symlinks and detecting hard-link aliases. */
|
|
6
|
+
export declare function assertOutputNotSource(inputs: readonly string[], target: string, cwd?: string): void;
|
|
7
|
+
/** image2's -n does not protect expanded sequence files. Check a supported pattern first. */
|
|
8
|
+
export declare function resolveFramePattern(input: string, explicit: string | undefined, overwrite: boolean, cwd?: string): string;
|
|
5
9
|
/**
|
|
6
10
|
* 决定输出路径:缺省时放在输入同目录,名字 = 输入名 + suffix + ext。
|
|
7
11
|
* 目标已存在且不允许覆写时自动追加 _1/_2…。
|
|
8
12
|
*/
|
|
9
|
-
export declare function resolveOutputPath(input: string, explicit: string | undefined, suffix: string, ext: string, overwrite: boolean, cwd?: string): string;
|
|
13
|
+
export declare function resolveOutputPath(input: string | readonly string[], explicit: string | undefined, suffix: string, ext: string, overwrite: boolean, cwd?: string): string;
|
package/lib/paths.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module dsh-ffmpeg/paths
|
|
5
5
|
*/
|
|
6
|
-
import { existsSync, statSync } from 'node:fs';
|
|
6
|
+
import { existsSync, statSync, lstatSync, realpathSync, mkdirSync, mkdtempSync, readdirSync } from 'node:fs';
|
|
7
7
|
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
8
8
|
/** 校验输入文件存在且是文件;返回绝对路径。 */
|
|
9
9
|
export function assertInputFile(input, cwd = process.cwd()) {
|
|
@@ -21,22 +21,96 @@ export function sanitizeName(name) {
|
|
|
21
21
|
const cleaned = name.trim().replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/\s+/g, '_').slice(0, 120);
|
|
22
22
|
return cleaned === '' ? 'output' : cleaned;
|
|
23
23
|
}
|
|
24
|
+
function canonicalPath(file) {
|
|
25
|
+
let parent = resolve(file);
|
|
26
|
+
const missing = [];
|
|
27
|
+
while (!existsSync(parent)) {
|
|
28
|
+
if (lstatSync(parent, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
29
|
+
throw new Error('输出路径包含悬空链接,已拒绝:' + file);
|
|
30
|
+
const next = dirname(parent);
|
|
31
|
+
if (next === parent)
|
|
32
|
+
throw new Error('无法解析输出路径:' + file);
|
|
33
|
+
missing.unshift(basename(parent));
|
|
34
|
+
parent = next;
|
|
35
|
+
}
|
|
36
|
+
const result = join(realpathSync(parent), ...missing);
|
|
37
|
+
return process.platform === 'win32' ? result.toLowerCase() : result;
|
|
38
|
+
}
|
|
39
|
+
/** Compare every source, resolving junctions/symlinks and detecting hard-link aliases. */
|
|
40
|
+
export function assertOutputNotSource(inputs, target, cwd = process.cwd()) {
|
|
41
|
+
const absolute = resolve(cwd, target), canonical = canonicalPath(absolute);
|
|
42
|
+
const targetStat = existsSync(absolute) ? statSync(absolute, { bigint: true }) : undefined;
|
|
43
|
+
for (const input of inputs) {
|
|
44
|
+
const source = resolve(cwd, input), sourceStat = existsSync(source) ? statSync(source, { bigint: true }) : undefined;
|
|
45
|
+
if (canonicalPath(source) === canonical || (targetStat && sourceStat && sourceStat.ino !== 0n && targetStat.ino === sourceStat.ino && targetStat.dev === sourceStat.dev)) {
|
|
46
|
+
throw new Error('输出路径与输入文件相同或指向同一文件,已拒绝(避免覆盖源文件)。');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** image2's -n does not protect expanded sequence files. Check a supported pattern first. */
|
|
51
|
+
export function resolveFramePattern(input, explicit, overwrite, cwd = process.cwd()) {
|
|
52
|
+
if (!explicit?.trim()) {
|
|
53
|
+
const directory = join(dirname(input), sanitizeName(basename(input, extname(input))) + '-frames');
|
|
54
|
+
mkdirSync(directory, { recursive: true });
|
|
55
|
+
return join(mkdtempSync(join(directory, 'run-')), sanitizeName(basename(input, extname(input))) + '-%03d.png');
|
|
56
|
+
}
|
|
57
|
+
let pattern = resolve(cwd, explicit.trim());
|
|
58
|
+
if (!basename(pattern).includes('%')) {
|
|
59
|
+
const ext = extname(pattern);
|
|
60
|
+
pattern = ext ? pattern.slice(0, -ext.length) + '-%03d' + ext : pattern + '-%03d.png';
|
|
61
|
+
}
|
|
62
|
+
if (dirname(pattern).includes('%'))
|
|
63
|
+
throw new Error('序列占位符只能放在输出文件名中,不能放在目录名中。');
|
|
64
|
+
const filename = basename(pattern);
|
|
65
|
+
let source = '^', count = 0;
|
|
66
|
+
const literal = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
67
|
+
for (let i = 0; i < filename.length;) {
|
|
68
|
+
if (filename[i] !== '%') {
|
|
69
|
+
source += literal(filename[i++]);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (filename[i + 1] === '%') {
|
|
73
|
+
source += '%';
|
|
74
|
+
i += 2;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const marker = /^%(?:0([1-9][0-9]?))?d/.exec(filename.slice(i));
|
|
78
|
+
if (!marker)
|
|
79
|
+
throw new Error('抽帧序列文件名只支持 %d 或 %0Nd(例如 %03d),字面的 % 请写成 %%。');
|
|
80
|
+
count++;
|
|
81
|
+
source += '[0-9]{' + (marker[1] ? Number(marker[1]) : 1) + ',}';
|
|
82
|
+
i += marker[0].length;
|
|
83
|
+
}
|
|
84
|
+
if (count !== 1)
|
|
85
|
+
throw new Error('抽帧序列文件名必须包含且只包含一个 %d 或 %0Nd。');
|
|
86
|
+
const matcher = new RegExp(source + '$', process.platform === 'win32' ? 'i' : '');
|
|
87
|
+
const directory = dirname(pattern);
|
|
88
|
+
mkdirSync(directory, { recursive: true });
|
|
89
|
+
const existing = readdirSync(directory).filter(name => matcher.test(name));
|
|
90
|
+
for (const name of existing)
|
|
91
|
+
assertOutputNotSource([input], join(directory, name), cwd);
|
|
92
|
+
if (!overwrite && existing.length)
|
|
93
|
+
throw new Error('抽帧输出已存在(' + existing.length + ' 个同名序列文件),已拒绝覆盖;请更换输出目录或文件名前缀。');
|
|
94
|
+
return pattern;
|
|
95
|
+
}
|
|
24
96
|
/**
|
|
25
97
|
* 决定输出路径:缺省时放在输入同目录,名字 = 输入名 + suffix + ext。
|
|
26
98
|
* 目标已存在且不允许覆写时自动追加 _1/_2…。
|
|
27
99
|
*/
|
|
28
100
|
export function resolveOutputPath(input, explicit, suffix, ext, overwrite, cwd = process.cwd()) {
|
|
101
|
+
const inputs = typeof input === 'string' ? [input] : input;
|
|
102
|
+
if (!inputs.length)
|
|
103
|
+
throw new Error('至少需要一个输入源文件。');
|
|
104
|
+
const primary = resolve(cwd, inputs[0]);
|
|
29
105
|
let target;
|
|
30
106
|
if (explicit !== undefined && explicit.trim() !== '') {
|
|
31
107
|
target = resolve(cwd, explicit.trim());
|
|
32
108
|
}
|
|
33
109
|
else {
|
|
34
|
-
const base = basename(
|
|
35
|
-
target = join(dirname(
|
|
36
|
-
}
|
|
37
|
-
if (target.toLowerCase() === resolve(input).toLowerCase()) {
|
|
38
|
-
throw new Error('输出路径与输入文件相同,已拒绝(避免覆盖源文件)。');
|
|
110
|
+
const base = basename(primary, extname(primary));
|
|
111
|
+
target = join(dirname(primary), sanitizeName(base) + suffix + ext);
|
|
39
112
|
}
|
|
113
|
+
assertOutputNotSource(inputs, target, cwd);
|
|
40
114
|
if (overwrite || !existsSync(target))
|
|
41
115
|
return target;
|
|
42
116
|
const directory = dirname(target);
|
package/lib/tools.js
CHANGED
|
@@ -10,7 +10,7 @@ import { tmpdir } from 'node:os';
|
|
|
10
10
|
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
11
11
|
import { adjustArgs, concatArgs, concatListContent, cutArgs, ENCODE_PRESETS, encodeArgs, extractArgs, fmtSeconds, frameAtArgs, gifPaletteArgs, gifUseArgs, probeArgs, subtitleArgs, } from './args.js';
|
|
12
12
|
import { parseProbeJson } from './ffprobe.js';
|
|
13
|
-
import { assertInputFile, resolveOutputPath, sanitizeName } from './paths.js';
|
|
13
|
+
import { assertInputFile, resolveOutputPath, resolveFramePattern, sanitizeName } from './paths.js';
|
|
14
14
|
import { parseTimeArg } from './config.js';
|
|
15
15
|
function executionCwd(exec) { return exec?.agent?.session?.header?.cwd || process.cwd(); }
|
|
16
16
|
/** 编译作者 DSL 为原始 JSON Schema(正是 defineTool 存为 definition.parameters 的值)。 */
|
|
@@ -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
|
-
const output = resolveOutputPath(absolute
|
|
285
|
+
const output = resolveOutputPath(absolute, 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
|
};
|
|
@@ -365,7 +391,7 @@ export function buildFfmpegTools(config, runner) {
|
|
|
365
391
|
const args = asRecord(rawArgs);
|
|
366
392
|
const input = assertInputFile(requiredString(args, 'input', '输入视频'), executionCwd(exec));
|
|
367
393
|
const subtitlePath = assertInputFile(requiredString(args, 'subtitle', '字幕文件'), executionCwd(exec));
|
|
368
|
-
const output = resolveOutputPath(input, optionalString(args, 'output'), '.sub', extname(input) || '.mp4', cfg.overwrite, executionCwd(exec));
|
|
394
|
+
const output = resolveOutputPath([input, subtitlePath], optionalString(args, 'output'), '.sub', extname(input) || '.mp4', cfg.overwrite, executionCwd(exec));
|
|
369
395
|
await runChecked(runner, subtitleArgs(cfg.ffmpegPath, { input, subtitle: subtitlePath, output, overwrite: cfg.overwrite }), timeout, 'ffmpeg 字幕', exec?.signal);
|
|
370
396
|
return { output, mode: 'burn' };
|
|
371
397
|
},
|
|
@@ -412,20 +438,7 @@ export function buildFfmpegTools(config, runner) {
|
|
|
412
438
|
output = resolveOutputPath(input, optionalString(args, 'output'), '.subtitle', '.srt', cfg.overwrite, executionCwd(exec));
|
|
413
439
|
}
|
|
414
440
|
else {
|
|
415
|
-
|
|
416
|
-
const explicit = rawOutput === undefined ? undefined : resolve(executionCwd(exec), rawOutput);
|
|
417
|
-
if (explicit !== undefined) {
|
|
418
|
-
if (explicit.includes('%')) {
|
|
419
|
-
output = explicit;
|
|
420
|
-
}
|
|
421
|
-
else {
|
|
422
|
-
const explicitExt = extname(explicit);
|
|
423
|
-
output = explicitExt === '' ? explicit + '-%03d.png' : explicit.slice(0, -explicitExt.length) + '-%03d' + explicitExt;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
else {
|
|
427
|
-
output = join(dirname(input), sanitizeName(basename(input, extname(input))) + '-%03d.png');
|
|
428
|
-
}
|
|
441
|
+
output = resolveFramePattern(input, optionalString(args, 'output'), cfg.overwrite, executionCwd(exec));
|
|
429
442
|
}
|
|
430
443
|
const spec = { input, what: what, output, overwrite: cfg.overwrite, start, duration, fps, streamIndex };
|
|
431
444
|
try {
|