@xiping/node-utils 1.0.61 → 1.0.62
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 +1 -2
- 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/runSync.d.ts +15 -0
- package/lib/src/ffmpeg/runSync.js +20 -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
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
|
}
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiping/node-utils",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.62",
|
|
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": "83ac9cffc81c4bbd5b78e1ecf621029630c98c20",
|
|
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
|
}
|