@xiping/node-utils 1.0.62 → 1.0.64
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/src/ffmpeg/README.md
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
| 缩略图生成 | 多帧合成预览图(AVIF/WebP/JPEG/PNG) | ffmpeg + sharp |
|
|
13
13
|
| 视频截取 | 按时间范围截取,流复制不重编码 | ffmpeg |
|
|
14
14
|
| 提取音频 | 从视频中提取音轨为 mp3/m4a/wav | ffmpeg |
|
|
15
|
+
| 视频转码 | HEVC/AV1 转码,支持转换进度回调 | ffmpeg |
|
|
15
16
|
| 环境检查 | 检测 ffmpeg/ffprobe 是否可用 | - |
|
|
16
17
|
|
|
17
18
|
## 安装要求
|
|
@@ -307,7 +308,79 @@ const result = await extractAudio('/path/to/video.mp4', {
|
|
|
307
308
|
|
|
308
309
|
---
|
|
309
310
|
|
|
310
|
-
## 5.
|
|
311
|
+
## 5. 视频转码(transcoding)
|
|
312
|
+
|
|
313
|
+
将视频转码为 HEVC 或 AV1,不依赖 shelljs,仅使用 Node 内置 `child_process`(spawn)与 `fs`。默认生成新文件(如 `{原名}_hevc.mp4` 或 `{原名}_av1.mp4`),不替换原文件;可选开启“替换原文件”。支持转换进度回调。
|
|
314
|
+
|
|
315
|
+
### 基本使用
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
import { transcoding, getAvailableEncoders } from '@xiping/node-utils';
|
|
319
|
+
|
|
320
|
+
// 转码为 HEVC(默认),生成新文件
|
|
321
|
+
const result = await transcoding('/path/to/video.mp4');
|
|
322
|
+
console.log(result.outputPath); // 如 /path/to/video_hevc.mp4
|
|
323
|
+
|
|
324
|
+
// 指定 AV1、不生成缩略图
|
|
325
|
+
const r2 = await transcoding('/path/to/video.mp4', {
|
|
326
|
+
format: 'av1',
|
|
327
|
+
generateThumbnail: false,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// 替换原文件(删除原文件并将输出重命名为原路径)
|
|
331
|
+
const r3 = await transcoding('/path/to/video.mp4', {
|
|
332
|
+
format: 'hevc',
|
|
333
|
+
replaceOriginal: true,
|
|
334
|
+
});
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### 转换进度(onProgress)
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
const result = await transcoding('/path/to/video.mp4', {
|
|
341
|
+
onProgress(progress) {
|
|
342
|
+
console.log(progress.phase, progress.percent, progress.message);
|
|
343
|
+
// phase: 'encoding' | 'done'
|
|
344
|
+
if (progress.currentTime != null && progress.duration != null) {
|
|
345
|
+
console.log(`已处理 ${progress.currentTime}/${progress.duration} 秒`);
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### TranscodingConfig
|
|
352
|
+
|
|
353
|
+
| 选项 | 类型 | 默认值 | 说明 |
|
|
354
|
+
|--------------------|----------|----------|------|
|
|
355
|
+
| format | string | 'hevc' | 目标编码:'hevc' \| 'av1' |
|
|
356
|
+
| generateThumbnail | boolean | true | 是否生成缩略图 |
|
|
357
|
+
| replaceOriginal | boolean | false | 是否替换原文件(删除原文件并将输出重命名为原路径) |
|
|
358
|
+
| onProgress | function | - | 转换进度回调 |
|
|
359
|
+
|
|
360
|
+
### TranscodeProgress(onProgress 回调参数)
|
|
361
|
+
|
|
362
|
+
| 字段 | 类型 | 说明 |
|
|
363
|
+
|-------------|--------|------|
|
|
364
|
+
| phase | string | 阶段:'encoding' \| 'done' |
|
|
365
|
+
| percent | number | 总进度 0–100 |
|
|
366
|
+
| message | string | 可读描述 |
|
|
367
|
+
| currentTime | number | 当前已处理时长(秒) |
|
|
368
|
+
| duration | number | 视频总时长(秒) |
|
|
369
|
+
|
|
370
|
+
### 编码器检测
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
import { getAvailableEncoders } from '@xiping/node-utils';
|
|
374
|
+
|
|
375
|
+
const encoders = getAvailableEncoders();
|
|
376
|
+
// { av1Nvenc, libaomAv1, hevcNvenc, libx265 }
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
HEVC 优先使用 hevc_nvenc(NVIDIA),否则使用 libx265;AV1 优先使用 av1_nvenc,否则使用 libaom-av1。输入路径需为**绝对路径**。
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
## 6. 环境检查(check)
|
|
311
384
|
|
|
312
385
|
```typescript
|
|
313
386
|
import { checkFFmpegAvailability, isFfprobeAvailable } from '@xiping/node-utils';
|
|
@@ -320,7 +393,7 @@ if (isFfprobeAvailable()) {
|
|
|
320
393
|
}
|
|
321
394
|
```
|
|
322
395
|
|
|
323
|
-
- **checkFFmpegAvailability()
|
|
396
|
+
- **checkFFmpegAvailability()**:用于缩略图、视频截取、提取音频、视频转码前检查。
|
|
324
397
|
- **isFfprobeAvailable()**:用于视频信息接口前检查。
|
|
325
398
|
|
|
326
399
|
---
|
|
@@ -366,5 +439,7 @@ ffprobe/ffmpeg 支持常见容器与编码,例如:
|
|
|
366
439
|
| `cutVideoByDuration(path, start, duration, options)` | 按起始+时长截取 |
|
|
367
440
|
| `cutVideoFromStart(path, durationSeconds, options)` | 从开头截取 N 秒 |
|
|
368
441
|
| `extractAudio(path, options)` | 从视频提取音频(异步) |
|
|
442
|
+
| `transcoding(path, options)` | 视频转码为 HEVC/AV1(异步,支持进度) |
|
|
443
|
+
| `getAvailableEncoders()` | 检测可用编码器(av1_nvenc、libaom-av1、hevc_nvenc、libx265) |
|
|
369
444
|
| `isFfprobeAvailable()` | 检测 ffprobe 是否可用 |
|
|
370
445
|
| `checkFFmpegAvailability()` | 检测 ffmpeg 是否可用 |
|
package/lib/src/ffmpeg/index.js
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** 转码进度信息,用于 onProgress 回调 */
|
|
2
|
+
export interface TranscodeProgress {
|
|
3
|
+
/** 当前阶段 */
|
|
4
|
+
phase: "encoding" | "done";
|
|
5
|
+
/** 总进度 0–100 */
|
|
6
|
+
percent: number;
|
|
7
|
+
/** 可读描述 */
|
|
8
|
+
message?: string;
|
|
9
|
+
/** 当前已处理时长(秒) */
|
|
10
|
+
currentTime?: number;
|
|
11
|
+
/** 视频总时长(秒) */
|
|
12
|
+
duration?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface TranscodingConfig {
|
|
15
|
+
/** 是否生成缩略图,默认 true */
|
|
16
|
+
generateThumbnail?: boolean;
|
|
17
|
+
/** 目标编码格式,默认 'hevc' */
|
|
18
|
+
format?: "av1" | "hevc";
|
|
19
|
+
/** 是否替换原文件(删除原文件并将输出重命名为原路径),默认 false */
|
|
20
|
+
replaceOriginal?: boolean;
|
|
21
|
+
/** 转换进度回调 */
|
|
22
|
+
onProgress?: (progress: TranscodeProgress) => void;
|
|
23
|
+
}
|
|
24
|
+
export interface AvailableEncoders {
|
|
25
|
+
av1Nvenc: boolean;
|
|
26
|
+
libaomAv1: boolean;
|
|
27
|
+
hevcNvenc: boolean;
|
|
28
|
+
libx265: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** 同步检测可用编码器 */
|
|
31
|
+
export declare function getAvailableEncoders(): AvailableEncoders;
|
|
32
|
+
export interface TranscodingResult {
|
|
33
|
+
/** 转码输出文件路径(替换原文件时为原路径,否则为 {base}_hevc.mp4 或 {base}_av1.mp4) */
|
|
34
|
+
outputPath: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 将视频转码为 HEVC 或 AV1,不依赖 shelljs,使用 Node 内置 child_process 与 fs。
|
|
38
|
+
* 支持转换进度回调;默认生成新文件,不替换原文件。
|
|
39
|
+
*/
|
|
40
|
+
export declare function transcoding(inputPath: string, config?: TranscodingConfig): Promise<TranscodingResult>;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { runSync } from "./runSync.js";
|
|
5
|
+
import { checkFFmpegAvailability } from "./check.js";
|
|
6
|
+
import { getThumbnail } from "./getThumbnail.js";
|
|
7
|
+
const log = (message, data) => {
|
|
8
|
+
console.log("[Transcode]", message, data !== undefined ? data : "");
|
|
9
|
+
};
|
|
10
|
+
const defaultConfig = {
|
|
11
|
+
generateThumbnail: true,
|
|
12
|
+
format: "hevc",
|
|
13
|
+
replaceOriginal: false,
|
|
14
|
+
};
|
|
15
|
+
/** 同步检测可用编码器 */
|
|
16
|
+
export function getAvailableEncoders() {
|
|
17
|
+
const result = runSync("ffmpeg", ["-encoders"]);
|
|
18
|
+
const stdout = result.code === 0 ? result.stdout : "";
|
|
19
|
+
return {
|
|
20
|
+
av1Nvenc: stdout.includes("av1_nvenc"),
|
|
21
|
+
libaomAv1: stdout.includes("libaom-av1"),
|
|
22
|
+
hevcNvenc: stdout.includes("hevc_nvenc"),
|
|
23
|
+
libx265: stdout.includes("libx265"),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function validateAndSanitizePath(inputPath) {
|
|
27
|
+
if (!inputPath || typeof inputPath !== "string") {
|
|
28
|
+
throw new Error("Invalid path provided");
|
|
29
|
+
}
|
|
30
|
+
if (!path.isAbsolute(inputPath)) {
|
|
31
|
+
throw new Error("Path must be absolute");
|
|
32
|
+
}
|
|
33
|
+
return inputPath;
|
|
34
|
+
}
|
|
35
|
+
function getDurationInSeconds(videoPath) {
|
|
36
|
+
const result = runSync("ffprobe", [
|
|
37
|
+
"-v",
|
|
38
|
+
"error",
|
|
39
|
+
"-show_entries",
|
|
40
|
+
"format=duration",
|
|
41
|
+
"-of",
|
|
42
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
43
|
+
videoPath,
|
|
44
|
+
]);
|
|
45
|
+
if (result.code !== 0) {
|
|
46
|
+
throw new Error(`FFprobe failed to get duration: ${result.stderr}`);
|
|
47
|
+
}
|
|
48
|
+
const duration = parseFloat(result.stdout);
|
|
49
|
+
if (isNaN(duration) || duration <= 0) {
|
|
50
|
+
throw new Error("Invalid video duration");
|
|
51
|
+
}
|
|
52
|
+
return duration;
|
|
53
|
+
}
|
|
54
|
+
/** 解析 ffmpeg stderr 中的 time=HH:MM:SS.xx 为秒数 */
|
|
55
|
+
function parseTimeFromStderr(line) {
|
|
56
|
+
const match = line.match(/time=(\d{2}):(\d{2}):(\d{2}\.\d+)/);
|
|
57
|
+
if (!match)
|
|
58
|
+
return null;
|
|
59
|
+
const [, h, m, s] = match;
|
|
60
|
+
return parseInt(h, 10) * 3600 + parseInt(m, 10) * 60 + parseFloat(s);
|
|
61
|
+
}
|
|
62
|
+
/** 根据格式与可用编码器构建 ffmpeg 参数数组 */
|
|
63
|
+
function buildTranscodeArgs(inputPath, outputPath, format, encoders) {
|
|
64
|
+
const args = ["-i", inputPath];
|
|
65
|
+
if (format === "av1") {
|
|
66
|
+
if (encoders.av1Nvenc) {
|
|
67
|
+
args.push("-c:v", "av1_nvenc", "-preset", "slow", "-rc", "constqp", "-qp", "28");
|
|
68
|
+
}
|
|
69
|
+
else if (encoders.libaomAv1) {
|
|
70
|
+
args.push("-c:v", "libaom-av1", "-crf", "30", "-b:v", "0");
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
throw new Error("没有可用的 AV1 编码器");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
if (encoders.hevcNvenc) {
|
|
78
|
+
args.push("-c:v", "hevc_nvenc", "-preset", "slow", "-rc", "constqp", "-qp", "28");
|
|
79
|
+
}
|
|
80
|
+
else if (encoders.libx265) {
|
|
81
|
+
args.push("-c:v", "libx265", "-crf", "28");
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
throw new Error("没有可用的 HEVC 编码器");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
args.push("-y", outputPath);
|
|
88
|
+
return args;
|
|
89
|
+
}
|
|
90
|
+
/** 使用 spawn 执行 ffmpeg 转码,解析 stderr 并回调 onProgress */
|
|
91
|
+
function runTranscodeWithProgress(inputPath, outputPath, format, encoders, totalDuration, onProgress) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const args = buildTranscodeArgs(inputPath, outputPath, format, encoders);
|
|
94
|
+
log("ffmpeg spawn", { totalDuration, format, outputPath });
|
|
95
|
+
const child = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
96
|
+
let lastPercent = -1;
|
|
97
|
+
let hasReportedProgress = false;
|
|
98
|
+
let loggedUnparsedTime = false;
|
|
99
|
+
const report = (progress) => {
|
|
100
|
+
if (progress.percent !== lastPercent) {
|
|
101
|
+
lastPercent = progress.percent;
|
|
102
|
+
hasReportedProgress = true;
|
|
103
|
+
if (progress.percent === 0 || progress.percent === 50 || progress.percent === 99 || progress.phase === "done") {
|
|
104
|
+
log("progress", { phase: progress.phase, percent: progress.percent, currentTime: progress.currentTime, duration: progress.duration });
|
|
105
|
+
}
|
|
106
|
+
onProgress(progress);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
if (child.stderr) {
|
|
110
|
+
let buffer = "";
|
|
111
|
+
child.stderr.setEncoding("utf8");
|
|
112
|
+
child.stderr.on("data", (chunk) => {
|
|
113
|
+
buffer += chunk;
|
|
114
|
+
const lines = buffer.split(/\r?\n/);
|
|
115
|
+
buffer = lines.pop() ?? "";
|
|
116
|
+
for (const line of lines) {
|
|
117
|
+
const currentTime = parseTimeFromStderr(line);
|
|
118
|
+
if (/time=\d/.test(line) && !loggedUnparsedTime && currentTime == null) {
|
|
119
|
+
loggedUnparsedTime = true;
|
|
120
|
+
log("stderr time line not parsed (check locale/format)", { line: line.trim() });
|
|
121
|
+
}
|
|
122
|
+
if (currentTime != null && totalDuration > 0) {
|
|
123
|
+
const percent = Math.min(99, Math.round((currentTime / totalDuration) * 100));
|
|
124
|
+
report({
|
|
125
|
+
phase: "encoding",
|
|
126
|
+
percent,
|
|
127
|
+
message: "正在转码...",
|
|
128
|
+
currentTime,
|
|
129
|
+
duration: totalDuration,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
child.on("error", (err) => {
|
|
136
|
+
log("ffmpeg error", err.message);
|
|
137
|
+
reject(err);
|
|
138
|
+
});
|
|
139
|
+
child.on("close", (code, signal) => {
|
|
140
|
+
log("ffmpeg close", { code, signal, hasReportedProgress });
|
|
141
|
+
if (signal) {
|
|
142
|
+
reject(new Error(`FFmpeg killed by signal: ${signal}`));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (code !== 0) {
|
|
146
|
+
reject(new Error(`FFmpeg exited with code ${code}`));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (!hasReportedProgress) {
|
|
150
|
+
log("no encoding progress was parsed from stderr (progress callbacks may not have fired)");
|
|
151
|
+
}
|
|
152
|
+
report({
|
|
153
|
+
phase: "done",
|
|
154
|
+
percent: 100,
|
|
155
|
+
message: "完成",
|
|
156
|
+
currentTime: totalDuration,
|
|
157
|
+
duration: totalDuration,
|
|
158
|
+
});
|
|
159
|
+
resolve();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* 将视频转码为 HEVC 或 AV1,不依赖 shelljs,使用 Node 内置 child_process 与 fs。
|
|
165
|
+
* 支持转换进度回调;默认生成新文件,不替换原文件。
|
|
166
|
+
*/
|
|
167
|
+
export async function transcoding(inputPath, config = {}) {
|
|
168
|
+
const finalConfig = { ...defaultConfig, ...config };
|
|
169
|
+
if (!checkFFmpegAvailability()) {
|
|
170
|
+
throw new Error("FFmpeg is not available. Please install FFmpeg first.");
|
|
171
|
+
}
|
|
172
|
+
const validatedPath = validateAndSanitizePath(inputPath);
|
|
173
|
+
if (!fs.existsSync(validatedPath)) {
|
|
174
|
+
throw new Error(`Video file not found: ${validatedPath}`);
|
|
175
|
+
}
|
|
176
|
+
const encoders = getAvailableEncoders();
|
|
177
|
+
const format = finalConfig.format;
|
|
178
|
+
const duration = getDurationInSeconds(validatedPath);
|
|
179
|
+
log("start", { inputPath: validatedPath, format, durationSeconds: duration, hasOnProgress: !!finalConfig.onProgress });
|
|
180
|
+
const dir = path.dirname(validatedPath);
|
|
181
|
+
const baseName = path.basename(validatedPath, path.extname(validatedPath));
|
|
182
|
+
const suffix = format === "av1" ? "_av1" : "_hevc";
|
|
183
|
+
const outputFileName = `${baseName}${suffix}.mp4`;
|
|
184
|
+
const tempOutputPath = path.join(dir, outputFileName);
|
|
185
|
+
const reportProgress = (progress) => {
|
|
186
|
+
finalConfig.onProgress?.(progress);
|
|
187
|
+
};
|
|
188
|
+
await runTranscodeWithProgress(validatedPath, tempOutputPath, format, encoders, duration, reportProgress);
|
|
189
|
+
if (finalConfig.generateThumbnail) {
|
|
190
|
+
log("generating thumbnail");
|
|
191
|
+
await getThumbnail(validatedPath);
|
|
192
|
+
}
|
|
193
|
+
let resultPath;
|
|
194
|
+
if (finalConfig.replaceOriginal) {
|
|
195
|
+
fs.unlinkSync(validatedPath);
|
|
196
|
+
const originalPath = path.join(dir, path.basename(validatedPath));
|
|
197
|
+
fs.renameSync(tempOutputPath, originalPath);
|
|
198
|
+
resultPath = originalPath;
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
resultPath = tempOutputPath;
|
|
202
|
+
}
|
|
203
|
+
log("done", { outputPath: resultPath });
|
|
204
|
+
return { outputPath: resultPath };
|
|
205
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiping/node-utils",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.64",
|
|
4
4
|
"description": "node-utils",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "The-End-Hero <527409987@qq.com>",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"bugs": {
|
|
26
26
|
"url": "https://github.com/The-End-Hero/xiping/issues"
|
|
27
27
|
},
|
|
28
|
-
"gitHead": "
|
|
28
|
+
"gitHead": "483df3f50a54f6d66b461c64bf05c77c68d368ad",
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"access": "public",
|
|
31
31
|
"registry": "https://registry.npmjs.org/"
|