@evio/ffai 1.0.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/README.md +111 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +26 -0
- package/dist/interface.d.ts +7 -0
- package/dist/interface.js +2 -0
- package/dist/lib.d.ts +45 -0
- package/dist/lib.js +263 -0
- package/dist/task.d.ts +2 -0
- package/dist/task.js +125 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# FFmpeg 视频处理工具
|
|
2
|
+
|
|
3
|
+
一个基于 FFmpeg 的命令行工具,用于批量处理视频和音频文件,支持视频转码、音频合并、字幕添加等功能。
|
|
4
|
+
|
|
5
|
+
## 功能特性
|
|
6
|
+
|
|
7
|
+
- 🎬 批量视频转码和拼接
|
|
8
|
+
- 🎵 多音频文件合并
|
|
9
|
+
- 📝 自动添加字幕(支持 SRT 格式)
|
|
10
|
+
- 🎲 智能视频选择(随机或全部模式)
|
|
11
|
+
- 📦 支持 glob 模式匹配文件
|
|
12
|
+
- ⚙️ 基于 JSON 配置文件的任务管理
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install
|
|
18
|
+
npm run build
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 使用方法
|
|
22
|
+
|
|
23
|
+
### 基本命令
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
ffai build <task-file.json>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### 任务配置文件
|
|
30
|
+
|
|
31
|
+
创建一个 JSON 配置文件来定义处理任务:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
[
|
|
35
|
+
{
|
|
36
|
+
"audios": ["audio1.mp3", "audio2.mp3"],
|
|
37
|
+
"videos": "videos/*.mp4",
|
|
38
|
+
"narration": "subtitle.srt",
|
|
39
|
+
"videoTranscodeable": true,
|
|
40
|
+
"videoPackMode": "random"
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 配置参数说明
|
|
46
|
+
|
|
47
|
+
- `audios`: 音频文件路径,支持数组或 glob 模式
|
|
48
|
+
- `videos`: 视频文件路径,支持数组或 glob 模式
|
|
49
|
+
- `narration`: 字幕文件路径(SRT 格式)
|
|
50
|
+
- `videoTranscodeable`: 是否对视频进行转码(可选,默认 false)
|
|
51
|
+
- `videoPackMode`: 视频打包模式
|
|
52
|
+
- `random`: 随机选择视频片段直到满足音频时长
|
|
53
|
+
- `all`: 使用所有视频文件
|
|
54
|
+
|
|
55
|
+
## 工作流程
|
|
56
|
+
|
|
57
|
+
1. 读取配置文件中的任务列表
|
|
58
|
+
2. 转码视频文件(如果启用)
|
|
59
|
+
3. 合并多个音频文件
|
|
60
|
+
4. 根据模式选择和拼接视频
|
|
61
|
+
5. 添加背景音频
|
|
62
|
+
6. 添加字幕
|
|
63
|
+
7. 输出最终视频到 `task-{index}/video.mp4`
|
|
64
|
+
|
|
65
|
+
## 示例
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# 使用配置文件执行任务
|
|
69
|
+
ffai build tasks.json
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
配置文件示例:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
[
|
|
76
|
+
{
|
|
77
|
+
"audios": ["bgm/*.mp3"],
|
|
78
|
+
"videos": ["clips/*.mp4"],
|
|
79
|
+
"narration": "subtitles/video1.srt",
|
|
80
|
+
"videoTranscodeable": true,
|
|
81
|
+
"videoPackMode": "random"
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 输出
|
|
87
|
+
|
|
88
|
+
每个任务会在当前目录下创建 `task-{index}` 文件夹,包含:
|
|
89
|
+
|
|
90
|
+
- `videos/`: 转码后的视频文件(如果启用转码)
|
|
91
|
+
- `audio.mp3`: 合并后的音频文件
|
|
92
|
+
- `video.mp4`: 最终输出的视频文件
|
|
93
|
+
|
|
94
|
+
## 依赖
|
|
95
|
+
|
|
96
|
+
- Node.js
|
|
97
|
+
- FFmpeg(需要系统安装)
|
|
98
|
+
|
|
99
|
+
## 开发
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# 开发模式
|
|
103
|
+
npm run dev
|
|
104
|
+
|
|
105
|
+
# 构建
|
|
106
|
+
npm run build
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
ISC
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const commander_1 = require("commander");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const task_1 = require("./task");
|
|
7
|
+
const program = new commander_1.Command();
|
|
8
|
+
const pkg = require('../package.json');
|
|
9
|
+
program
|
|
10
|
+
.name(pkg.name)
|
|
11
|
+
.description(pkg.description)
|
|
12
|
+
.version(pkg.version);
|
|
13
|
+
program
|
|
14
|
+
.command('build <file>')
|
|
15
|
+
.action(async (file) => {
|
|
16
|
+
const taskFile = (0, node_path_1.resolve)(process.cwd(), file);
|
|
17
|
+
if (!taskFile.endsWith('.json')) {
|
|
18
|
+
throw new Error(`Task file must end with '.json' extension. Received: ${file}`);
|
|
19
|
+
}
|
|
20
|
+
const taskPkgValue = require(taskFile);
|
|
21
|
+
for (let i = 0; i < taskPkgValue.length; i++) {
|
|
22
|
+
const props = taskPkgValue[i];
|
|
23
|
+
await (0, task_1.RunTask)(props, i);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
program.parseAsync();
|
package/dist/lib.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 获取媒体文件(mp3/mp4)的播放时长
|
|
3
|
+
* @param filePath 文件路径
|
|
4
|
+
* @returns 返回时长(秒)
|
|
5
|
+
*/
|
|
6
|
+
export declare function getMediaDuration(filePath: string): Promise<number>;
|
|
7
|
+
/**
|
|
8
|
+
* 批量转码视频文件为 mp4 格式
|
|
9
|
+
* @param directory 源文件目录
|
|
10
|
+
* @param suffix 源文件后缀名
|
|
11
|
+
* @param temp 输出目录
|
|
12
|
+
* @param _log 可选的日志回调函数
|
|
13
|
+
* @returns 返回转码后的文件路径数组
|
|
14
|
+
*/
|
|
15
|
+
export declare function transcodeVideos(directory: string, suffix: string, temp: string, _log?: (msg: string) => void): Promise<string[]>;
|
|
16
|
+
export declare function transcodeVideo(input: string, output: string): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* 合并多个视频文件为一个视频
|
|
19
|
+
* @param files 视频文件路径数组
|
|
20
|
+
* @param outputFilePath 输出文件路径
|
|
21
|
+
* @returns Promise
|
|
22
|
+
*/
|
|
23
|
+
export declare function concatVideos(files: string[], outputFilePath: string, tmp?: string): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* 为视频添加背景音频
|
|
26
|
+
* @param inputVideoPath 输入视频路径
|
|
27
|
+
* @param outputVideoPath 输出视频路径
|
|
28
|
+
* @param audioPath 音频文件路径
|
|
29
|
+
*/
|
|
30
|
+
export declare function addBackgroundAudio(inputVideoPath: string, outputVideoPath: string, audioPath: string): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* 合并多个音频文件(.mp3) - 重叠播放(混音)
|
|
33
|
+
* @param audioFiles 音频文件路径数组
|
|
34
|
+
* @param outputFilePath 输出文件路径
|
|
35
|
+
*/
|
|
36
|
+
export declare function mergeAudioFiles(audioFiles: string[], outputFilePath: string): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* 为视频添加字幕
|
|
39
|
+
* @param inputfile 输入视频文件路径
|
|
40
|
+
* @param outputfile 输出视频文件路径
|
|
41
|
+
* @param texts 字幕文件路径
|
|
42
|
+
* @param fontfile 可选的字体文件路径
|
|
43
|
+
* @returns Promise
|
|
44
|
+
*/
|
|
45
|
+
export declare function addSubTitles(inputfile: string, outputfile: string, texts: string, fontfile?: string): Promise<void>;
|
package/dist/lib.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getMediaDuration = getMediaDuration;
|
|
7
|
+
exports.transcodeVideos = transcodeVideos;
|
|
8
|
+
exports.transcodeVideo = transcodeVideo;
|
|
9
|
+
exports.concatVideos = concatVideos;
|
|
10
|
+
exports.addBackgroundAudio = addBackgroundAudio;
|
|
11
|
+
exports.mergeAudioFiles = mergeAudioFiles;
|
|
12
|
+
exports.addSubTitles = addSubTitles;
|
|
13
|
+
const fluent_ffmpeg_1 = __importDefault(require("fluent-ffmpeg"));
|
|
14
|
+
const dayjs_1 = __importDefault(require("dayjs"));
|
|
15
|
+
const node_fs_1 = require("node:fs");
|
|
16
|
+
const node_path_1 = require("node:path");
|
|
17
|
+
/**
|
|
18
|
+
* 获取媒体文件(mp3/mp4)的播放时长
|
|
19
|
+
* @param filePath 文件路径
|
|
20
|
+
* @returns 返回时长(秒)
|
|
21
|
+
*/
|
|
22
|
+
function getMediaDuration(filePath) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
fluent_ffmpeg_1.default.ffprobe(filePath, (err, metadata) => {
|
|
25
|
+
if (err) {
|
|
26
|
+
return reject(err);
|
|
27
|
+
}
|
|
28
|
+
const duration = metadata.format.duration;
|
|
29
|
+
if (duration === undefined) {
|
|
30
|
+
return reject(new Error('无法获取文件时长'));
|
|
31
|
+
}
|
|
32
|
+
resolve(duration);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 批量转码视频文件为 mp4 格式
|
|
38
|
+
* @param directory 源文件目录
|
|
39
|
+
* @param suffix 源文件后缀名
|
|
40
|
+
* @param temp 输出目录
|
|
41
|
+
* @param _log 可选的日志回调函数
|
|
42
|
+
* @returns 返回转码后的文件路径数组
|
|
43
|
+
*/
|
|
44
|
+
async function transcodeVideos(directory, suffix, temp, _log) {
|
|
45
|
+
const files = (0, node_fs_1.readdirSync)(directory).filter((f) => f.endsWith(suffix));
|
|
46
|
+
const _files = [];
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
const inputFilePath = (0, node_path_1.resolve)(directory, file);
|
|
49
|
+
const outputFilePath = (0, node_path_1.resolve)(temp, file.replace(suffix, '.mp4'));
|
|
50
|
+
await new Promise((resolve, reject) => {
|
|
51
|
+
(0, fluent_ffmpeg_1.default)(inputFilePath)
|
|
52
|
+
.outputOptions([
|
|
53
|
+
"-c:v libx264",
|
|
54
|
+
"-c:a aac",
|
|
55
|
+
"-ar 44100", // 音频采样率
|
|
56
|
+
"-ac 2", // 音频声道
|
|
57
|
+
])
|
|
58
|
+
.on("error", reject)
|
|
59
|
+
.on("end", () => resolve())
|
|
60
|
+
.save(outputFilePath);
|
|
61
|
+
});
|
|
62
|
+
if (_log)
|
|
63
|
+
_log(`Transcoded ${inputFilePath} to ${outputFilePath}`);
|
|
64
|
+
_files.push(outputFilePath);
|
|
65
|
+
}
|
|
66
|
+
return _files;
|
|
67
|
+
}
|
|
68
|
+
function transcodeVideo(input, output) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
(0, fluent_ffmpeg_1.default)(input)
|
|
71
|
+
.outputOptions([
|
|
72
|
+
"-c:v libx264",
|
|
73
|
+
"-c:a aac",
|
|
74
|
+
"-ar 44100", // 音频采样率
|
|
75
|
+
"-ac 2", // 音频声道
|
|
76
|
+
])
|
|
77
|
+
.on("error", reject)
|
|
78
|
+
.on("end", () => resolve())
|
|
79
|
+
.save(output);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 合并多个视频文件为一个视频
|
|
84
|
+
* @param files 视频文件路径数组
|
|
85
|
+
* @param outputFilePath 输出文件路径
|
|
86
|
+
* @returns Promise
|
|
87
|
+
*/
|
|
88
|
+
function concatVideos(files, outputFilePath, tmp) {
|
|
89
|
+
return new Promise((_resolve, _reject) => {
|
|
90
|
+
if (!files?.length) {
|
|
91
|
+
return _reject(new Error("视频文件列表为空"));
|
|
92
|
+
}
|
|
93
|
+
const videoFiles = (0, node_path_1.resolve)(tmp || (0, node_path_1.dirname)(files[0]), "videofiles.txt");
|
|
94
|
+
const fileListContent = files
|
|
95
|
+
.map((file) => `file '${file}'`)
|
|
96
|
+
.join("\n");
|
|
97
|
+
(0, node_fs_1.writeFileSync)(videoFiles, fileListContent, 'utf8');
|
|
98
|
+
(0, fluent_ffmpeg_1.default)()
|
|
99
|
+
.input(videoFiles)
|
|
100
|
+
.inputOptions(["-f concat", "-safe 0"])
|
|
101
|
+
.outputOptions(["-c:v libx264", "-c:a aac"])
|
|
102
|
+
// .on("start", (cmd) => console.log("合并视频:", cmd, '\n'))
|
|
103
|
+
// .on("progress", (progress) => {
|
|
104
|
+
// if (progress.percent) {
|
|
105
|
+
// process.stdout.write(`\rProgress: ${(progress.percent / 1000).toFixed(2)}%`);
|
|
106
|
+
// }
|
|
107
|
+
// })
|
|
108
|
+
.on("error", _reject)
|
|
109
|
+
.on("end", () => _resolve())
|
|
110
|
+
.save(outputFilePath);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 为视频添加背景音频
|
|
115
|
+
* @param inputVideoPath 输入视频路径
|
|
116
|
+
* @param outputVideoPath 输出视频路径
|
|
117
|
+
* @param audioPath 音频文件路径
|
|
118
|
+
*/
|
|
119
|
+
function addBackgroundAudio(inputVideoPath, outputVideoPath, audioPath) {
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
(0, fluent_ffmpeg_1.default)()
|
|
122
|
+
.input(inputVideoPath) // 视频输入
|
|
123
|
+
.input(audioPath) // 音频输入
|
|
124
|
+
.outputOptions([
|
|
125
|
+
"-map 0:v", // 使用第一个输入(视频)的视频流
|
|
126
|
+
"-map 1:a", // 使用第二个输入(音频)的音频流
|
|
127
|
+
"-c:v copy", // 直接复制视频流,不重新编码
|
|
128
|
+
"-c:a aac", // 使用AAC音频编码器
|
|
129
|
+
"-b:a 192k", // 设置音频比特率
|
|
130
|
+
"-ar 44100", // 设置音频采样率
|
|
131
|
+
"-ac 2", // 设置声道数
|
|
132
|
+
"-strict experimental", // 确保AAC编码器可用
|
|
133
|
+
"-shortest" // 使输出视频长度与较短的输入(视频或音频)一致
|
|
134
|
+
])
|
|
135
|
+
// .on("start", (cmd) => console.log("添加背景音频命令:", cmd, '\n'))
|
|
136
|
+
// .on("progress", (progress) => {
|
|
137
|
+
// if (progress.percent) {
|
|
138
|
+
// process.stdout.write(`\rProgress: ${(progress.percent).toFixed(2)}%`);
|
|
139
|
+
// }
|
|
140
|
+
// })
|
|
141
|
+
.on("error", reject)
|
|
142
|
+
.on("end", () => resolve())
|
|
143
|
+
.save(outputVideoPath);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* 合并多个音频文件(.mp3) - 重叠播放(混音)
|
|
148
|
+
* @param audioFiles 音频文件路径数组
|
|
149
|
+
* @param outputFilePath 输出文件路径
|
|
150
|
+
*/
|
|
151
|
+
function mergeAudioFiles(audioFiles, outputFilePath) {
|
|
152
|
+
return new Promise((_resolve, reject) => {
|
|
153
|
+
if (!audioFiles?.length) {
|
|
154
|
+
return reject(new Error("音频文件列表为空"));
|
|
155
|
+
}
|
|
156
|
+
// 创建FFmpeg命令实例
|
|
157
|
+
const cmd = (0, fluent_ffmpeg_1.default)();
|
|
158
|
+
// 添加所有音频文件作为输入
|
|
159
|
+
audioFiles.forEach((file) => {
|
|
160
|
+
cmd.input(file);
|
|
161
|
+
});
|
|
162
|
+
// 计算输入数量
|
|
163
|
+
const inputCount = audioFiles.length;
|
|
164
|
+
// 设置音频混音滤镜
|
|
165
|
+
// 使用 complexFilter 来处理多个输入流的混合
|
|
166
|
+
// amix 滤镜会将多个音频流混合在一起,默认会自动调整音量
|
|
167
|
+
// 使用 amix=inputs=N:duration=first 参数来保持与第一个输入相同的时长
|
|
168
|
+
cmd.complexFilter(`amix=inputs=${inputCount}:duration=first`);
|
|
169
|
+
// 设置输出选项
|
|
170
|
+
cmd.outputOptions([
|
|
171
|
+
"-c:a libmp3lame", // 使用MP3编码器
|
|
172
|
+
"-b:a 192k", // 设置音频比特率
|
|
173
|
+
"-ar 44100", // 设置音频采样率
|
|
174
|
+
"-ac 2" // 设置声道数
|
|
175
|
+
])
|
|
176
|
+
// .on("start", (cmd) => console.log("混音音频命令:", cmd, '\n'))
|
|
177
|
+
// .on("progress", (progress) => {
|
|
178
|
+
// if (progress.percent) {
|
|
179
|
+
// process.stdout.write(`\rProgress: ${(progress.percent).toFixed(2)}%`);
|
|
180
|
+
// }
|
|
181
|
+
// })
|
|
182
|
+
.on("error", reject)
|
|
183
|
+
.on("end", () => _resolve())
|
|
184
|
+
.save(outputFilePath);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* 解析字幕文件,将时间格式转换为秒数
|
|
189
|
+
* @param file 字幕文件路径,格式为 "HH:MM-HH:MM 文本内容"
|
|
190
|
+
* @returns 返回包含开始时间、结束时间和文本的对象数组
|
|
191
|
+
*/
|
|
192
|
+
function transformSubTitleVars(file) {
|
|
193
|
+
const content = (0, node_fs_1.readFileSync)(file, 'utf8');
|
|
194
|
+
const items = content.split('\n').map(line => line.trim());
|
|
195
|
+
const today = (0, dayjs_1.default)().format('YYYY-MM-DD HH');
|
|
196
|
+
const now = (0, dayjs_1.default)(today + ':00:00');
|
|
197
|
+
return items.map(item => {
|
|
198
|
+
const _exec = /^(\d\d\:\d\d)\-(\d\d\:\d\d)\s(.+)$/.exec(item);
|
|
199
|
+
if (!_exec)
|
|
200
|
+
return null;
|
|
201
|
+
const start = (0, dayjs_1.default)(today + ':' + _exec[1]);
|
|
202
|
+
const end = (0, dayjs_1.default)(today + ':' + _exec[2]);
|
|
203
|
+
const text = _exec[3];
|
|
204
|
+
return {
|
|
205
|
+
start: start.diff(now, 'seconds'),
|
|
206
|
+
end: end.diff(now, 'seconds'),
|
|
207
|
+
text,
|
|
208
|
+
};
|
|
209
|
+
}).filter(Boolean);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* 为视频添加字幕
|
|
213
|
+
* @param inputfile 输入视频文件路径
|
|
214
|
+
* @param outputfile 输出视频文件路径
|
|
215
|
+
* @param texts 字幕文件路径
|
|
216
|
+
* @param fontfile 可选的字体文件路径
|
|
217
|
+
* @returns Promise
|
|
218
|
+
*/
|
|
219
|
+
function addSubTitles(inputfile, outputfile, texts, fontfile) {
|
|
220
|
+
const subTitles = transformSubTitleVars(texts);
|
|
221
|
+
// console.log(subTitles);
|
|
222
|
+
const videoFilters = subTitles.map((e) => {
|
|
223
|
+
return {
|
|
224
|
+
filter: "drawtext",
|
|
225
|
+
options: {
|
|
226
|
+
fontfile,
|
|
227
|
+
text: e.text,
|
|
228
|
+
fontsize: 36, // 增大字体大小,确保清晰可见
|
|
229
|
+
fontcolor: "white", // 文字颜色设置为白色
|
|
230
|
+
box: 1, // 启用背景框
|
|
231
|
+
boxcolor: "black@0.7", // 半透明黑色背景,使用ffmpeg支持的格式
|
|
232
|
+
boxborderw: 4, // 调整边框宽度
|
|
233
|
+
// max_width: "w*0.8", // 最大宽度为视频宽度的80%,超过则自动换行
|
|
234
|
+
// line_spacing: 8, // 行间距为8像素
|
|
235
|
+
x: "(w-text_w)/2", // 水平居中
|
|
236
|
+
y: "(h-text_h-200)", // 底部以上100像素的位置,确保在视频范围内
|
|
237
|
+
enable: `between(t,${e.start},${e.end})`,
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
(0, fluent_ffmpeg_1.default)(inputfile)
|
|
243
|
+
.videoFilters(videoFilters)
|
|
244
|
+
.videoCodec("libx264")
|
|
245
|
+
.outputOptions([
|
|
246
|
+
"-preset veryfast",
|
|
247
|
+
"-crf 18",
|
|
248
|
+
"-pix_fmt yuv420p",
|
|
249
|
+
"-r 25", // 目标帧率
|
|
250
|
+
"-y" // 覆盖输出文件,避免文件存在时的权限问题
|
|
251
|
+
])
|
|
252
|
+
.audioCodec("copy") // 直接复制音频流,避免重新编码
|
|
253
|
+
// .on("start", (cmd) => {
|
|
254
|
+
// console.log("ffmpeg (subtitle) cmd:", cmd);
|
|
255
|
+
// })
|
|
256
|
+
// .on("stderr", (line) => {
|
|
257
|
+
// console.log("[ffmpeg-subtitle err]", line);
|
|
258
|
+
// })
|
|
259
|
+
.on("end", () => resolve())
|
|
260
|
+
.on("error", (err) => reject(err))
|
|
261
|
+
.save(outputfile);
|
|
262
|
+
});
|
|
263
|
+
}
|
package/dist/task.d.ts
ADDED
package/dist/task.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RunTask = RunTask;
|
|
4
|
+
const node_path_1 = require("node:path");
|
|
5
|
+
const glob_1 = require("glob");
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const lib_1 = require("./lib");
|
|
8
|
+
const fontfile = '/System/Library/Fonts/Supplemental/Songti.ttc';
|
|
9
|
+
async function RunTask(props, index) {
|
|
10
|
+
console.log(`开始第${index + 1}个任务:`);
|
|
11
|
+
const cwd = process.cwd();
|
|
12
|
+
const directory = (0, node_path_1.resolve)(cwd, 'task-' + index);
|
|
13
|
+
const narrationFile = (0, node_path_1.resolve)(cwd, props.narration);
|
|
14
|
+
if (!(0, node_fs_1.existsSync)(narrationFile))
|
|
15
|
+
throw new Error('缺少旁白文件');
|
|
16
|
+
if (!(0, node_fs_1.existsSync)(directory))
|
|
17
|
+
(0, node_fs_1.mkdirSync)(directory);
|
|
18
|
+
const videosData = await parseVideos(props.videoTranscodeable, props.videos, directory);
|
|
19
|
+
const audioData = await parseAudios(props.audios, directory);
|
|
20
|
+
const output_concat = (0, node_path_1.resolve)(directory, 'video_concat.mp4');
|
|
21
|
+
const output_concat_audio = (0, node_path_1.resolve)(directory, 'video_concat_audio.mp4');
|
|
22
|
+
const output_concat_audio_subtitle = (0, node_path_1.resolve)(directory, 'video.mp4');
|
|
23
|
+
const packVideos = [];
|
|
24
|
+
if (props.videoPackMode === 'random') {
|
|
25
|
+
const secs = videosData.map(i => i.sec);
|
|
26
|
+
const picks = findVideosBySec(secs, audioData.sec);
|
|
27
|
+
for (let i = 0; i < picks.length; i++) {
|
|
28
|
+
packVideos.push(videosData[picks[i]].file);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
for (let i = 0; i < videosData.length; i++) {
|
|
33
|
+
packVideos.push(videosData[i].file);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const videoTmpDir = (0, node_path_1.resolve)(directory, 'tmp');
|
|
37
|
+
if (!(0, node_fs_1.existsSync)(videoTmpDir))
|
|
38
|
+
(0, node_fs_1.mkdirSync)(videoTmpDir);
|
|
39
|
+
await (0, lib_1.concatVideos)(packVideos, output_concat, videoTmpDir);
|
|
40
|
+
await (0, lib_1.addBackgroundAudio)(output_concat, output_concat_audio, audioData.file);
|
|
41
|
+
await (0, lib_1.addSubTitles)(output_concat_audio, output_concat_audio_subtitle, narrationFile, fontfile);
|
|
42
|
+
// 清理
|
|
43
|
+
(0, node_fs_1.unlinkSync)((0, node_path_1.resolve)(videoTmpDir, 'videofiles.txt'));
|
|
44
|
+
(0, node_fs_1.unlinkSync)(output_concat);
|
|
45
|
+
(0, node_fs_1.unlinkSync)(output_concat_audio);
|
|
46
|
+
console.log('+', output_concat_audio_subtitle);
|
|
47
|
+
}
|
|
48
|
+
async function getVideoFiles(videos) {
|
|
49
|
+
const cwd = process.cwd();
|
|
50
|
+
if (Array.isArray(videos)) {
|
|
51
|
+
return videos.map(file => (0, node_path_1.resolve)(cwd, file));
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const files = await (0, glob_1.glob)(videos, { cwd });
|
|
55
|
+
return files.map(file => (0, node_path_1.resolve)(cwd, file));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function getAudioFiles(audios) {
|
|
59
|
+
const cwd = process.cwd();
|
|
60
|
+
if (Array.isArray(audios)) {
|
|
61
|
+
return audios.map(file => (0, node_path_1.resolve)(cwd, file));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const files = await (0, glob_1.glob)(audios, { cwd });
|
|
65
|
+
return files.map(file => (0, node_path_1.resolve)(cwd, file));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function parseVideos(transcodeable, videos, directory) {
|
|
69
|
+
let files = await getVideoFiles(videos);
|
|
70
|
+
if (transcodeable) {
|
|
71
|
+
const to = (0, node_path_1.resolve)(directory, 'videos');
|
|
72
|
+
if (!(0, node_fs_1.existsSync)(to))
|
|
73
|
+
(0, node_fs_1.mkdirSync)(to);
|
|
74
|
+
let newFiles = [];
|
|
75
|
+
for (let i = 0; i < files.length; i++) {
|
|
76
|
+
const outfile = (0, node_path_1.resolve)(to, `${i + 1}.mp4`);
|
|
77
|
+
await (0, lib_1.transcodeVideo)(files[i], outfile);
|
|
78
|
+
newFiles.push(outfile);
|
|
79
|
+
}
|
|
80
|
+
files = newFiles;
|
|
81
|
+
}
|
|
82
|
+
const result = [];
|
|
83
|
+
for (let i = 0; i < files.length; i++) {
|
|
84
|
+
const sec = Math.ceil(await (0, lib_1.getMediaDuration)(files[i]));
|
|
85
|
+
result.push({
|
|
86
|
+
file: files[i],
|
|
87
|
+
sec,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
async function parseAudios(audios, directory) {
|
|
93
|
+
const files = await getAudioFiles(audios);
|
|
94
|
+
const file = (0, node_path_1.resolve)(directory, 'audio.mp3');
|
|
95
|
+
await (0, lib_1.mergeAudioFiles)(files, file);
|
|
96
|
+
const sec = Math.ceil(await (0, lib_1.getMediaDuration)(file));
|
|
97
|
+
return {
|
|
98
|
+
file,
|
|
99
|
+
sec,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 从列表中随机选取数字,累加直到达到或超过目标秒数
|
|
104
|
+
* @param list 秒数数组
|
|
105
|
+
* @param sec 目标秒数阈值
|
|
106
|
+
* @returns 返回选取的索引数组
|
|
107
|
+
*/
|
|
108
|
+
function findVideosBySec(list, sec) {
|
|
109
|
+
const result = [];
|
|
110
|
+
const available = new Set(list.map((_, index) => index)); // 可用索引集合
|
|
111
|
+
let sum = 0;
|
|
112
|
+
while (sum < sec && available.size > 0) {
|
|
113
|
+
// 将可用索引转为数组以便随机选取
|
|
114
|
+
const availableArray = Array.from(available);
|
|
115
|
+
// 随机选取一个索引
|
|
116
|
+
const randomIndex = Math.floor(Math.random() * availableArray.length);
|
|
117
|
+
const selectedIndex = availableArray[randomIndex];
|
|
118
|
+
// 将选中的索引加入结果
|
|
119
|
+
result.push(selectedIndex);
|
|
120
|
+
sum += list[selectedIndex];
|
|
121
|
+
// 从可用集合中移除已选索引
|
|
122
|
+
available.delete(selectedIndex);
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@evio/ffai",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "一个基于 FFmpeg 的命令行工具,用于批量处理视频和音频文件,支持视频转码、音频合并、字幕添加等功能。",
|
|
5
|
+
"main": "dist/lib.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "ts-node src/index.ts",
|
|
8
|
+
"fix": "fix-esm-import-path --preserve-import-type ./dist",
|
|
9
|
+
"build": "rm -rf ./dist && mkdir ./dist && tsc -d && npm run fix"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"ffai": "dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [],
|
|
18
|
+
"author": "",
|
|
19
|
+
"license": "ISC",
|
|
20
|
+
"type": "commonjs",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"commander": "^14.0.2",
|
|
23
|
+
"dayjs": "1.11.19",
|
|
24
|
+
"fluent-ffmpeg": "^2.1.3",
|
|
25
|
+
"glob": "^13.0.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/fluent-ffmpeg": "^2.1.27",
|
|
29
|
+
"@types/node": "^22.0.0",
|
|
30
|
+
"dayjs": "1.11.19",
|
|
31
|
+
"glob": "^13.0.0",
|
|
32
|
+
"ts-node": "^10.9.2",
|
|
33
|
+
"typescript": "^5.4.0",
|
|
34
|
+
"fix-esm-import-path": "^1.10.1"
|
|
35
|
+
}
|
|
36
|
+
}
|