@xiping/node-utils 1.0.61 → 1.0.63
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 +45 -0
- package/lib/src/ffmpeg/README.md +78 -4
- package/lib/src/ffmpeg/check.js +3 -3
- package/lib/src/ffmpeg/cutVideo.js +17 -21
- package/lib/src/ffmpeg/extractAudio.js +10 -6
- package/lib/src/ffmpeg/getThumbnail.js +16 -3
- package/lib/src/ffmpeg/getVideoInfo.js +20 -7
- package/lib/src/ffmpeg/index.d.ts +1 -0
- package/lib/src/ffmpeg/index.js +1 -0
- package/lib/src/ffmpeg/runSync.d.ts +15 -0
- package/lib/src/ffmpeg/runSync.js +20 -0
- package/lib/src/ffmpeg/transcode.d.ts +40 -0
- package/lib/src/ffmpeg/transcode.js +181 -0
- package/package.json +7 -9
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @xiping/node-utils
|
|
2
|
+
|
|
3
|
+
Node.js 通用工具库,提供目录树、路径、SRT→VTT 字幕转换、FFmpeg 视频处理、文件信息与图片处理等能力。
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @xiping/node-utils
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## 使用案例
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import {
|
|
13
|
+
buildDirectoryTree,
|
|
14
|
+
getParentDirectory,
|
|
15
|
+
convertSrtFileToVttFile,
|
|
16
|
+
getVideoInfo,
|
|
17
|
+
getThumbnail,
|
|
18
|
+
getFileConfig,
|
|
19
|
+
convertImage,
|
|
20
|
+
checkFFmpegAvailability,
|
|
21
|
+
} from '@xiping/node-utils';
|
|
22
|
+
|
|
23
|
+
// 目录树
|
|
24
|
+
const tree = buildDirectoryTree('/path/to/dir');
|
|
25
|
+
|
|
26
|
+
// 路径
|
|
27
|
+
const parent = getParentDirectory('/Users/documents/folder');
|
|
28
|
+
|
|
29
|
+
// SRT 转 VTT
|
|
30
|
+
convertSrtFileToVttFile('/path/to/subtitle.srt');
|
|
31
|
+
|
|
32
|
+
// 视频信息与缩略图(需系统安装 ffmpeg)
|
|
33
|
+
if (checkFFmpegAvailability()) {
|
|
34
|
+
const info = getVideoInfo('/path/to/video.mp4');
|
|
35
|
+
const thumb = await getThumbnail('/path/to/video.mp4', { frames: 30 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 文件配置(含视频元数据)
|
|
39
|
+
const config = getFileConfig('/path/to/file.mp4');
|
|
40
|
+
|
|
41
|
+
// 图片格式转换
|
|
42
|
+
await convertImage(inputPath, outputPath, 'webp', { quality: 80 });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
更多说明见 [src/ffmpeg/README.md](./src/ffmpeg/README.md)、[src/image/README.md](./src/image/README.md)、[src/srt-to-vtt/README.md](./src/srt-to-vtt/README.md)。
|
package/lib/src/ffmpeg/README.md
CHANGED
|
@@ -12,14 +12,14 @@
|
|
|
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
|
## 安装要求
|
|
18
19
|
|
|
19
20
|
### 依赖包
|
|
20
21
|
|
|
21
|
-
- Node.js
|
|
22
|
-
- [shelljs](https://www.npmjs.com/package/shelljs)
|
|
22
|
+
- Node.js(使用内置 `child_process.spawnSync` 调用 ffmpeg/ffprobe,兼容 Electron、跨平台)
|
|
23
23
|
- [sharp](https://www.npmjs.com/package/sharp)(仅缩略图功能需要)
|
|
24
24
|
|
|
25
25
|
### 系统要求
|
|
@@ -308,7 +308,79 @@ const result = await extractAudio('/path/to/video.mp4', {
|
|
|
308
308
|
|
|
309
309
|
---
|
|
310
310
|
|
|
311
|
-
## 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)
|
|
312
384
|
|
|
313
385
|
```typescript
|
|
314
386
|
import { checkFFmpegAvailability, isFfprobeAvailable } from '@xiping/node-utils';
|
|
@@ -321,7 +393,7 @@ if (isFfprobeAvailable()) {
|
|
|
321
393
|
}
|
|
322
394
|
```
|
|
323
395
|
|
|
324
|
-
- **checkFFmpegAvailability()
|
|
396
|
+
- **checkFFmpegAvailability()**:用于缩略图、视频截取、提取音频、视频转码前检查。
|
|
325
397
|
- **isFfprobeAvailable()**:用于视频信息接口前检查。
|
|
326
398
|
|
|
327
399
|
---
|
|
@@ -367,5 +439,7 @@ ffprobe/ffmpeg 支持常见容器与编码,例如:
|
|
|
367
439
|
| `cutVideoByDuration(path, start, duration, options)` | 按起始+时长截取 |
|
|
368
440
|
| `cutVideoFromStart(path, durationSeconds, options)` | 从开头截取 N 秒 |
|
|
369
441
|
| `extractAudio(path, options)` | 从视频提取音频(异步) |
|
|
442
|
+
| `transcoding(path, options)` | 视频转码为 HEVC/AV1(异步,支持进度) |
|
|
443
|
+
| `getAvailableEncoders()` | 检测可用编码器(av1_nvenc、libaom-av1、hevc_nvenc、libx265) |
|
|
370
444
|
| `isFfprobeAvailable()` | 检测 ffprobe 是否可用 |
|
|
371
445
|
| `checkFFmpegAvailability()` | 检测 ffmpeg 是否可用 |
|
package/lib/src/ffmpeg/check.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { runSync } from "./runSync.js";
|
|
2
2
|
// 检查 ffmpeg 可用性
|
|
3
3
|
export function checkFFmpegAvailability() {
|
|
4
|
-
const result =
|
|
4
|
+
const result = runSync("ffmpeg", ["-version"]);
|
|
5
5
|
return result.code === 0;
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
@@ -9,6 +9,6 @@ export function checkFFmpegAvailability() {
|
|
|
9
9
|
* @returns 是否可用
|
|
10
10
|
*/
|
|
11
11
|
export function isFfprobeAvailable() {
|
|
12
|
-
const result =
|
|
12
|
+
const result = runSync("ffprobe", ["-version"]);
|
|
13
13
|
return result.code === 0;
|
|
14
14
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import shell from "shelljs";
|
|
2
1
|
import * as fs from "fs";
|
|
2
|
+
import { runSync } from "./runSync.js";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as os from "node:os";
|
|
5
5
|
import { checkFFmpegAvailability } from "./check.js";
|
|
@@ -40,7 +40,12 @@ function normalizeTimeFormat(time) {
|
|
|
40
40
|
// 获取视频时长
|
|
41
41
|
function getDurationInSeconds(videoPath) {
|
|
42
42
|
log('Getting video duration', { videoPath });
|
|
43
|
-
const result =
|
|
43
|
+
const result = runSync("ffprobe", [
|
|
44
|
+
"-v", "error",
|
|
45
|
+
"-show_entries", "format=duration",
|
|
46
|
+
"-of", "default=noprint_wrappers=1:nokey=1",
|
|
47
|
+
videoPath,
|
|
48
|
+
]);
|
|
44
49
|
if (result.code !== 0) {
|
|
45
50
|
throw new Error(`FFprobe failed to get duration: ${result.stderr}`);
|
|
46
51
|
}
|
|
@@ -104,20 +109,6 @@ function timeToSeconds(timeStr) {
|
|
|
104
109
|
}
|
|
105
110
|
throw new Error(`Invalid time format: ${timeStr}`);
|
|
106
111
|
}
|
|
107
|
-
// 构建 FFmpeg 命令
|
|
108
|
-
function buildFFmpegCommand(inputPath, outputPath, startTime, duration, options) {
|
|
109
|
-
const { overwrite = false } = options;
|
|
110
|
-
// 使用 copy 模式,保持原视频编码和质量
|
|
111
|
-
let command = `ffmpeg -ss ${startTime} -i "${inputPath}" -t ${duration}`;
|
|
112
|
-
// 复制视频和音频流,不重新编码
|
|
113
|
-
command += ' -c copy';
|
|
114
|
-
// 其他设置
|
|
115
|
-
command += ' -avoid_negative_ts make_zero';
|
|
116
|
-
command += ' -y'; // 自动覆盖输出文件
|
|
117
|
-
// 输出文件
|
|
118
|
-
command += ` "${outputPath}"`;
|
|
119
|
-
return command;
|
|
120
|
-
}
|
|
121
112
|
// 临时目录管理
|
|
122
113
|
async function withTempDir(fn, customTempDir) {
|
|
123
114
|
const tempDir = customTempDir || fs.mkdtempSync(path.join(os.tmpdir(), "video-cut-"));
|
|
@@ -169,11 +160,16 @@ export const cutVideo = async (videoPath, options = {}) => {
|
|
|
169
160
|
// 使用临时目录处理
|
|
170
161
|
await withTempDir(async (tempDir) => {
|
|
171
162
|
const tempOutputPath = path.join(tempDir, `temp_output.${outputFormat}`);
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
163
|
+
log('Executing FFmpeg command', { startTime: timeParams.startTime, duration: timeParams.duration });
|
|
164
|
+
const result = runSync("ffmpeg", [
|
|
165
|
+
"-ss", timeParams.startTime,
|
|
166
|
+
"-i", validatedPath,
|
|
167
|
+
"-t", timeParams.duration,
|
|
168
|
+
"-c", "copy",
|
|
169
|
+
"-avoid_negative_ts", "make_zero",
|
|
170
|
+
"-y",
|
|
171
|
+
tempOutputPath,
|
|
172
|
+
]);
|
|
177
173
|
if (result.code !== 0) {
|
|
178
174
|
throw new Error(`FFmpeg failed: ${result.stderr}`);
|
|
179
175
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import shell from "shelljs";
|
|
2
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { runSync } from "./runSync.js";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import * as os from "node:os";
|
|
@@ -18,7 +18,12 @@ function validateAndSanitizePath(inputPath) {
|
|
|
18
18
|
return inputPath;
|
|
19
19
|
}
|
|
20
20
|
function getDurationInSeconds(videoPath) {
|
|
21
|
-
const result =
|
|
21
|
+
const result = runSync("ffprobe", [
|
|
22
|
+
"-v", "error",
|
|
23
|
+
"-show_entries", "format=duration",
|
|
24
|
+
"-of", "default=noprint_wrappers=1:nokey=1",
|
|
25
|
+
videoPath,
|
|
26
|
+
]);
|
|
22
27
|
if (result.code !== 0) {
|
|
23
28
|
throw new Error(`FFprobe failed to get duration: ${result.stderr}`);
|
|
24
29
|
}
|
|
@@ -57,13 +62,12 @@ function buildFfmpegArgs(outputPath, outputFormat, copyStream, audioCodec) {
|
|
|
57
62
|
args.push("-y", outputPath);
|
|
58
63
|
return args;
|
|
59
64
|
}
|
|
60
|
-
/** 执行 ffmpeg(
|
|
65
|
+
/** 执行 ffmpeg(runSync),无进度 */
|
|
61
66
|
function runFfmpegSync(inputPath, outputPath, outputFormat, copyStream, audioCodec) {
|
|
62
67
|
const args = buildFfmpegArgs(outputPath, outputFormat, copyStream, audioCodec);
|
|
63
68
|
args[1] = inputPath;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const result = shell.exec(command, { silent: true });
|
|
69
|
+
log("Executing FFmpeg command", { inputPath, outputPath });
|
|
70
|
+
const result = runSync("ffmpeg", args);
|
|
67
71
|
if (result.code !== 0) {
|
|
68
72
|
throw new Error(`FFmpeg failed: ${result.stderr}`);
|
|
69
73
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import shell from "shelljs";
|
|
2
1
|
import Sharp from "sharp";
|
|
2
|
+
import { runSync } from "./runSync.js";
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import * as os from "node:os";
|
|
@@ -22,7 +22,12 @@ function validateAndSanitizePath(inputPath) {
|
|
|
22
22
|
// 获取视频时长
|
|
23
23
|
function getDurationInSeconds(videoPath) {
|
|
24
24
|
log('Getting video duration', { videoPath });
|
|
25
|
-
const result =
|
|
25
|
+
const result = runSync("ffprobe", [
|
|
26
|
+
"-v", "error",
|
|
27
|
+
"-show_entries", "format=duration",
|
|
28
|
+
"-of", "default=noprint_wrappers=1:nokey=1",
|
|
29
|
+
videoPath,
|
|
30
|
+
]);
|
|
26
31
|
if (result.code !== 0) {
|
|
27
32
|
throw new Error(`FFprobe failed to get duration: ${result.stderr}`);
|
|
28
33
|
}
|
|
@@ -40,7 +45,15 @@ async function extractFrames(videoPath, tempDir, frames, interval, maxConcurrenc
|
|
|
40
45
|
const t = interval * i;
|
|
41
46
|
const framePath = path.join(tempDir, `frame-${t}.jpg`);
|
|
42
47
|
return new Promise((resolve, reject) => {
|
|
43
|
-
const result =
|
|
48
|
+
const result = runSync("ffmpeg", [
|
|
49
|
+
"-ss", String(t),
|
|
50
|
+
"-i", videoPath,
|
|
51
|
+
"-vframes", "1",
|
|
52
|
+
"-q:v", "10",
|
|
53
|
+
"-an",
|
|
54
|
+
"-threads", "4",
|
|
55
|
+
framePath,
|
|
56
|
+
]);
|
|
44
57
|
if (result.code === 0) {
|
|
45
58
|
log(`Frame ${i + 1}/${total} extracted`, { time: t });
|
|
46
59
|
resolve();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { runSync } from "./runSync.js";
|
|
2
3
|
/**
|
|
3
4
|
* 格式化文件大小
|
|
4
5
|
* @param bytes 字节数
|
|
@@ -32,12 +33,16 @@ function formatDuration(seconds) {
|
|
|
32
33
|
* @returns 视频信息对象
|
|
33
34
|
*/
|
|
34
35
|
export function getVideoInfo(videoPath) {
|
|
35
|
-
|
|
36
|
-
if (!shell.test("-f", videoPath)) {
|
|
36
|
+
if (!fs.existsSync(videoPath)) {
|
|
37
37
|
throw new Error(`视频文件不存在: ${videoPath}`);
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
const result = runSync("ffprobe", [
|
|
40
|
+
"-v", "quiet",
|
|
41
|
+
"-print_format", "json",
|
|
42
|
+
"-show_format",
|
|
43
|
+
"-show_streams",
|
|
44
|
+
videoPath,
|
|
45
|
+
]);
|
|
41
46
|
if (result.code !== 0) {
|
|
42
47
|
throw new Error(`获取视频信息失败: ${result.stderr}`);
|
|
43
48
|
}
|
|
@@ -98,10 +103,18 @@ export function getMultipleVideoInfo(videoPaths) {
|
|
|
98
103
|
* @returns 详细的视频信息
|
|
99
104
|
*/
|
|
100
105
|
export function getDetailedVideoInfo(videoPath) {
|
|
101
|
-
if (!
|
|
106
|
+
if (!fs.existsSync(videoPath)) {
|
|
102
107
|
throw new Error(`视频文件不存在: ${videoPath}`);
|
|
103
108
|
}
|
|
104
|
-
const result =
|
|
109
|
+
const result = runSync("ffprobe", [
|
|
110
|
+
"-v", "quiet",
|
|
111
|
+
"-print_format", "json",
|
|
112
|
+
"-show_format",
|
|
113
|
+
"-show_streams",
|
|
114
|
+
"-show_chapters",
|
|
115
|
+
"-show_private_data",
|
|
116
|
+
videoPath,
|
|
117
|
+
]);
|
|
105
118
|
if (result.code !== 0) {
|
|
106
119
|
throw new Error(`获取详细视频信息失败: ${result.stderr}`);
|
|
107
120
|
}
|
package/lib/src/ffmpeg/index.js
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步执行外部命令(跨平台、兼容 Electron,不依赖 shell)。
|
|
3
|
+
* 使用 spawnSync + 参数数组,避免 shell 解析和路径/空格问题。
|
|
4
|
+
*
|
|
5
|
+
* @param command 可执行文件名(如 ffmpeg、ffprobe)
|
|
6
|
+
* @param args 参数列表(路径无需额外引号)
|
|
7
|
+
* @returns 与 shelljs.exec 兼容的 { code, stdout, stderr }
|
|
8
|
+
*/
|
|
9
|
+
export declare function runSync(command: string, args: string[], options?: {
|
|
10
|
+
encoding?: BufferEncoding;
|
|
11
|
+
}): {
|
|
12
|
+
code: number;
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* 同步执行外部命令(跨平台、兼容 Electron,不依赖 shell)。
|
|
4
|
+
* 使用 spawnSync + 参数数组,避免 shell 解析和路径/空格问题。
|
|
5
|
+
*
|
|
6
|
+
* @param command 可执行文件名(如 ffmpeg、ffprobe)
|
|
7
|
+
* @param args 参数列表(路径无需额外引号)
|
|
8
|
+
* @returns 与 shelljs.exec 兼容的 { code, stdout, stderr }
|
|
9
|
+
*/
|
|
10
|
+
export function runSync(command, args, options) {
|
|
11
|
+
const encoding = options?.encoding ?? "utf8";
|
|
12
|
+
const result = spawnSync(command, args, {
|
|
13
|
+
encoding,
|
|
14
|
+
windowsHide: true,
|
|
15
|
+
});
|
|
16
|
+
const stdout = (result.stdout ?? "");
|
|
17
|
+
const stderr = (result.stderr ?? "");
|
|
18
|
+
const code = result.status ?? (result.signal ? -1 : 0);
|
|
19
|
+
return { code, stdout, stderr };
|
|
20
|
+
}
|
|
@@ -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,181 @@
|
|
|
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 defaultConfig = {
|
|
8
|
+
generateThumbnail: true,
|
|
9
|
+
format: "hevc",
|
|
10
|
+
replaceOriginal: false,
|
|
11
|
+
};
|
|
12
|
+
/** 同步检测可用编码器 */
|
|
13
|
+
export function getAvailableEncoders() {
|
|
14
|
+
const result = runSync("ffmpeg", ["-encoders"]);
|
|
15
|
+
const stdout = result.code === 0 ? result.stdout : "";
|
|
16
|
+
return {
|
|
17
|
+
av1Nvenc: stdout.includes("av1_nvenc"),
|
|
18
|
+
libaomAv1: stdout.includes("libaom-av1"),
|
|
19
|
+
hevcNvenc: stdout.includes("hevc_nvenc"),
|
|
20
|
+
libx265: stdout.includes("libx265"),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function validateAndSanitizePath(inputPath) {
|
|
24
|
+
if (!inputPath || typeof inputPath !== "string") {
|
|
25
|
+
throw new Error("Invalid path provided");
|
|
26
|
+
}
|
|
27
|
+
if (!path.isAbsolute(inputPath)) {
|
|
28
|
+
throw new Error("Path must be absolute");
|
|
29
|
+
}
|
|
30
|
+
return inputPath;
|
|
31
|
+
}
|
|
32
|
+
function getDurationInSeconds(videoPath) {
|
|
33
|
+
const result = runSync("ffprobe", [
|
|
34
|
+
"-v",
|
|
35
|
+
"error",
|
|
36
|
+
"-show_entries",
|
|
37
|
+
"format=duration",
|
|
38
|
+
"-of",
|
|
39
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
40
|
+
videoPath,
|
|
41
|
+
]);
|
|
42
|
+
if (result.code !== 0) {
|
|
43
|
+
throw new Error(`FFprobe failed to get duration: ${result.stderr}`);
|
|
44
|
+
}
|
|
45
|
+
const duration = parseFloat(result.stdout);
|
|
46
|
+
if (isNaN(duration) || duration <= 0) {
|
|
47
|
+
throw new Error("Invalid video duration");
|
|
48
|
+
}
|
|
49
|
+
return duration;
|
|
50
|
+
}
|
|
51
|
+
/** 解析 ffmpeg stderr 中的 time=HH:MM:SS.xx 为秒数 */
|
|
52
|
+
function parseTimeFromStderr(line) {
|
|
53
|
+
const match = line.match(/time=(\d{2}):(\d{2}):(\d{2}\.\d+)/);
|
|
54
|
+
if (!match)
|
|
55
|
+
return null;
|
|
56
|
+
const [, h, m, s] = match;
|
|
57
|
+
return parseInt(h, 10) * 3600 + parseInt(m, 10) * 60 + parseFloat(s);
|
|
58
|
+
}
|
|
59
|
+
/** 根据格式与可用编码器构建 ffmpeg 参数数组 */
|
|
60
|
+
function buildTranscodeArgs(inputPath, outputPath, format, encoders) {
|
|
61
|
+
const args = ["-i", inputPath];
|
|
62
|
+
if (format === "av1") {
|
|
63
|
+
if (encoders.av1Nvenc) {
|
|
64
|
+
args.push("-c:v", "av1_nvenc", "-preset", "slow", "-rc", "constqp", "-qp", "28");
|
|
65
|
+
}
|
|
66
|
+
else if (encoders.libaomAv1) {
|
|
67
|
+
args.push("-c:v", "libaom-av1", "-crf", "30", "-b:v", "0");
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
throw new Error("没有可用的 AV1 编码器");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
if (encoders.hevcNvenc) {
|
|
75
|
+
args.push("-c:v", "hevc_nvenc", "-preset", "slow", "-rc", "constqp", "-qp", "28");
|
|
76
|
+
}
|
|
77
|
+
else if (encoders.libx265) {
|
|
78
|
+
args.push("-c:v", "libx265", "-crf", "28");
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
throw new Error("没有可用的 HEVC 编码器");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
args.push("-y", outputPath);
|
|
85
|
+
return args;
|
|
86
|
+
}
|
|
87
|
+
/** 使用 spawn 执行 ffmpeg 转码,解析 stderr 并回调 onProgress */
|
|
88
|
+
function runTranscodeWithProgress(inputPath, outputPath, format, encoders, totalDuration, onProgress) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const args = buildTranscodeArgs(inputPath, outputPath, format, encoders);
|
|
91
|
+
const child = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
92
|
+
let lastPercent = -1;
|
|
93
|
+
const report = (progress) => {
|
|
94
|
+
if (progress.percent !== lastPercent) {
|
|
95
|
+
lastPercent = progress.percent;
|
|
96
|
+
onProgress(progress);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
if (child.stderr) {
|
|
100
|
+
let buffer = "";
|
|
101
|
+
child.stderr.setEncoding("utf8");
|
|
102
|
+
child.stderr.on("data", (chunk) => {
|
|
103
|
+
buffer += chunk;
|
|
104
|
+
const lines = buffer.split(/\r?\n/);
|
|
105
|
+
buffer = lines.pop() ?? "";
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
const currentTime = parseTimeFromStderr(line);
|
|
108
|
+
if (currentTime != null && totalDuration > 0) {
|
|
109
|
+
const percent = Math.min(99, Math.round((currentTime / totalDuration) * 100));
|
|
110
|
+
report({
|
|
111
|
+
phase: "encoding",
|
|
112
|
+
percent,
|
|
113
|
+
message: "正在转码...",
|
|
114
|
+
currentTime,
|
|
115
|
+
duration: totalDuration,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
child.on("error", (err) => reject(err));
|
|
122
|
+
child.on("close", (code, signal) => {
|
|
123
|
+
if (signal) {
|
|
124
|
+
reject(new Error(`FFmpeg killed by signal: ${signal}`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (code !== 0) {
|
|
128
|
+
reject(new Error(`FFmpeg exited with code ${code}`));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
report({
|
|
132
|
+
phase: "done",
|
|
133
|
+
percent: 100,
|
|
134
|
+
message: "完成",
|
|
135
|
+
currentTime: totalDuration,
|
|
136
|
+
duration: totalDuration,
|
|
137
|
+
});
|
|
138
|
+
resolve();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 将视频转码为 HEVC 或 AV1,不依赖 shelljs,使用 Node 内置 child_process 与 fs。
|
|
144
|
+
* 支持转换进度回调;默认生成新文件,不替换原文件。
|
|
145
|
+
*/
|
|
146
|
+
export async function transcoding(inputPath, config = {}) {
|
|
147
|
+
const finalConfig = { ...defaultConfig, ...config };
|
|
148
|
+
if (!checkFFmpegAvailability()) {
|
|
149
|
+
throw new Error("FFmpeg is not available. Please install FFmpeg first.");
|
|
150
|
+
}
|
|
151
|
+
const validatedPath = validateAndSanitizePath(inputPath);
|
|
152
|
+
if (!fs.existsSync(validatedPath)) {
|
|
153
|
+
throw new Error(`Video file not found: ${validatedPath}`);
|
|
154
|
+
}
|
|
155
|
+
const encoders = getAvailableEncoders();
|
|
156
|
+
const format = finalConfig.format;
|
|
157
|
+
const duration = getDurationInSeconds(validatedPath);
|
|
158
|
+
const dir = path.dirname(validatedPath);
|
|
159
|
+
const baseName = path.basename(validatedPath, path.extname(validatedPath));
|
|
160
|
+
const suffix = format === "av1" ? "_av1" : "_hevc";
|
|
161
|
+
const outputFileName = `${baseName}${suffix}.mp4`;
|
|
162
|
+
const tempOutputPath = path.join(dir, outputFileName);
|
|
163
|
+
const reportProgress = (progress) => {
|
|
164
|
+
finalConfig.onProgress?.(progress);
|
|
165
|
+
};
|
|
166
|
+
await runTranscodeWithProgress(validatedPath, tempOutputPath, format, encoders, duration, reportProgress);
|
|
167
|
+
if (finalConfig.generateThumbnail) {
|
|
168
|
+
await getThumbnail(validatedPath);
|
|
169
|
+
}
|
|
170
|
+
let resultPath;
|
|
171
|
+
if (finalConfig.replaceOriginal) {
|
|
172
|
+
fs.unlinkSync(validatedPath);
|
|
173
|
+
const originalPath = path.join(dir, path.basename(validatedPath));
|
|
174
|
+
fs.renameSync(tempOutputPath, originalPath);
|
|
175
|
+
resultPath = originalPath;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
resultPath = tempOutputPath;
|
|
179
|
+
}
|
|
180
|
+
return { outputPath: resultPath };
|
|
181
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiping/node-utils",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.63",
|
|
4
4
|
"description": "node-utils",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "The-End-Hero <527409987@qq.com>",
|
|
@@ -16,16 +16,16 @@
|
|
|
16
16
|
],
|
|
17
17
|
"repository": {
|
|
18
18
|
"type": "git",
|
|
19
|
-
"url": "git+https://github.com/The-End-Hero/
|
|
19
|
+
"url": "git+https://github.com/The-End-Hero/xiping.git"
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
22
|
"test": "echo \"Error: run tests from root\" && exit 1",
|
|
23
23
|
"build": "tsc && node scripts/copy-readmes.js"
|
|
24
24
|
},
|
|
25
25
|
"bugs": {
|
|
26
|
-
"url": "https://github.com/The-End-Hero/
|
|
26
|
+
"url": "https://github.com/The-End-Hero/xiping/issues"
|
|
27
27
|
},
|
|
28
|
-
"gitHead": "
|
|
28
|
+
"gitHead": "466588a53ef8fa8bc6562a9933f1c9d707e978e5",
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"access": "public",
|
|
31
31
|
"registry": "https://registry.npmjs.org/"
|
|
@@ -33,14 +33,12 @@
|
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@xiping/subtitle": "1.0.52",
|
|
35
35
|
"chalk": "^5.6.2",
|
|
36
|
-
"
|
|
37
|
-
"sharp": "^0.34.4",
|
|
38
|
-
"shelljs": "0.10.0",
|
|
36
|
+
"sharp": "^0.34.5",
|
|
39
37
|
"srt-parser-2": "^1.2.3"
|
|
40
38
|
},
|
|
41
39
|
"devDependencies": {
|
|
42
|
-
"@types/node": "^24.10.
|
|
40
|
+
"@types/node": "^24.10.13",
|
|
43
41
|
"tslib": "^2.8.1",
|
|
44
|
-
"typescript": "^5.9.
|
|
42
|
+
"typescript": "^5.9.3"
|
|
45
43
|
}
|
|
46
44
|
}
|